Add OpenAI-compatible LLM provider (URL/model/key configurable)

- New provider 'openai' calls any OpenAI-compatible /chat/completions endpoint
  (OpenAI, OpenRouter, Together, Groq, NVIDIA, vLLM, Ollama, ...), set via
  OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL. Now the default; auto-detects.
- Robustness for models that degenerate (e.g. Kimi repetition loops): anti-
  repetition sampling (temperature + frequency penalty), and a schema-aware
  retry — call_json(require_keys=...) retries a fresh sample on degenerate or
  unparseable output (LLMRetryable). Relevance requires 'signals', synth 'briefs'.
- response_format=json_object (toggle via OPENAI_JSON_MODE); base URL accepts a
  full /chat/completions URL or a /v1 base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:19:52 -04:00
parent b4e3ed9d9b
commit 2374ef137d
6 changed files with 135 additions and 22 deletions

View File

@@ -28,9 +28,31 @@ def _load_dotenv() -> None:
_load_dotenv()
# --- LLM ---
# Provider abstraction: gemini | anthropic | mock. If LLM_PROVIDER is unset we
# auto-detect from whichever key is present (Gemini preferred), falling back to
# a deterministic mock reasoner so the loop still runs offline.
# Provider abstraction: openai | gemini | anthropic | mock. If LLM_PROVIDER is
# unset we auto-detect from whichever key is present (OpenAI-compatible first),
# falling back to a deterministic mock reasoner so the loop still runs offline.
#
# "openai" is any OpenAI-compatible /chat/completions endpoint — OpenAI, OpenRouter,
# Together, Groq, vLLM, Ollama, LM Studio, etc. — configured by three env vars:
# OPENAI_BASE_URL (e.g. https://api.openai.com/v1)
# OPENAI_API_KEY
# OPENAI_MODEL (e.g. gpt-4o-mini)
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini").strip()
# Some endpoints/models don't support response_format=json_object — turn off if so.
OPENAI_JSON_MODE = os.environ.get("OPENAI_JSON_MODE", "1").strip() == "1"
# Sampling/anti-repetition. A small frequency penalty + non-trivial temperature
# avoids the repetition-loop degeneration some models (e.g. Kimi) fall into at
# very low temperature. Penalties are omitted from the request when set to 0
# (for endpoints that reject them).
OPENAI_TEMPERATURE = float(os.environ.get("OPENAI_TEMPERATURE", "0.5"))
OPENAI_FREQUENCY_PENALTY = float(os.environ.get("OPENAI_FREQUENCY_PENALTY", "0.4"))
OPENAI_PRESENCE_PENALTY = float(os.environ.get("OPENAI_PRESENCE_PENALTY", "0.0"))
# Retries when a model returns degenerate/unparseable content (stochastic — a
# retry usually succeeds). Distinct from the network/429 backoff.
LLM_CONTENT_RETRIES = int(os.environ.get("LLM_CONTENT_RETRIES", "3"))
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6")
@@ -39,7 +61,9 @@ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
_provider = os.environ.get("LLM_PROVIDER", "").strip().lower()
if not _provider:
if GEMINI_API_KEY:
if OPENAI_API_KEY:
_provider = "openai"
elif GEMINI_API_KEY:
_provider = "gemini"
elif ANTHROPIC_API_KEY:
_provider = "anthropic"
@@ -48,6 +72,8 @@ if not _provider:
# A forced USE_MOCK_LLM=1, or selecting a provider whose key is missing, => mock.
if os.environ.get("USE_MOCK_LLM", "").strip() == "1":
_provider = "mock"
elif _provider == "openai" and not OPENAI_API_KEY:
_provider = "mock"
elif _provider == "gemini" and not GEMINI_API_KEY:
_provider = "mock"
elif _provider == "anthropic" and not ANTHROPIC_API_KEY:
@@ -60,6 +86,9 @@ USE_MOCK_LLM = LLM_PROVIDER == "mock"
def llm_label() -> str:
if LLM_PROVIDER == "mock":
return "MOCK reasoner (no LLM key)"
if LLM_PROVIDER == "openai":
host = OPENAI_BASE_URL.split("//")[-1].split("/")[0]
return f"OpenAI-compatible ({OPENAI_MODEL} @ {host})"
if LLM_PROVIDER == "gemini":
return f"Gemini ({GEMINI_MODEL})"
return f"Anthropic ({LLM_MODEL})"