From 7f1e297e239aecc613f8f1723681c444650d01e5 Mon Sep 17 00:00:00 2001 From: dimknaf <136385722+dimknaf@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:34:08 +0100 Subject: [PATCH] feat(config): add openai_compatible LLM profile Point the internal agent at any OpenAI-compatible /v1 endpoint (Ollama, LM Studio, copilot-api, a remote vLLM) without a new provider abstraction: set LLM_PROFILE=openai_compatible, AGENT_MODEL=openai/, and OPENAI_BASE_URL (OPENAI_API_KEY only if the endpoint needs auth). - new profile reuses the existing model-prefix + api_key_env mechanism - the env-driven base_url is scoped to this profile ONLY; resolved_base_url special-cases it so no other profile (deepinfra/nim/vllm_*) can be silently re-pointed - provider-prefix env names (OPENAI_*) to match DEEPINFRA_API_KEY etc. - unit coverage incl. a regression that OPENAI_BASE_URL does not leak into other profiles; docker-compose env passthrough, .env.example, README, CONTRIBUTING, CHANGELOG Reworked from @WarGloom's proposal in #7 (thanks for the nudge), adjusted to the provider-prefix convention and a profile-scoped override per review. --- .env.example | 11 ++++- CHANGELOG.md | 12 +++++ CONTRIBUTING.md | 4 +- README.md | 21 ++++++-- braindb/config.py | 22 ++++++++- docker-compose.yml | 3 ++ tests/test_config_profiles.py | 93 +++++++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 tests/test_config_profiles.py diff --git a/.env.example b/.env.example index 84e8c4f..b74c681 100644 --- a/.env.example +++ b/.env.example @@ -43,7 +43,7 @@ HF_TOKEN= # LLM provider profile — which LiteLLM backend the agent talks to. # Options are defined in braindb/config.py::_LLM_PROFILES -# (currently: nim, deepinfra, vllm_workstation). +# (currently: nim, deepinfra, openai_compatible, vllm_workstation). LLM_PROFILE=deepinfra # Provider API keys — fill in whichever profile you're using. @@ -56,6 +56,15 @@ DEEPINFRA_API_KEY= # Set this only if your vLLM was started with --api-key. VLLM_API_KEY= +# Generic OpenAI-compatible endpoint (Ollama, LM Studio, copilot-api, vLLM, ...). +# Used ONLY when LLM_PROFILE=openai_compatible. Point at the server's /v1 URL +# and set AGENT_MODEL to the served model with the openai/ prefix. Leave +# OPENAI_API_KEY blank for local servers without auth (resolver supplies "EMPTY"). +# LLM_PROFILE=openai_compatible +# AGENT_MODEL=openai/llama3.2:3b +# OPENAI_BASE_URL=http://host.docker.internal:11434/v1 +OPENAI_API_KEY= + # Optional: override the profile's default model string (e.g. to try a smaller variant). # Leave blank to use the profile's built-in default. AGENT_MODEL= diff --git a/CHANGELOG.md b/CHANGELOG.md index 58bae5e..38fa06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`openai_compatible` LLM profile.** Point the internal agent at any + OpenAI-compatible `/v1` endpoint (Ollama, LM Studio, copilot-api, a remote + vLLM) without adding a provider abstraction: set + `LLM_PROFILE=openai_compatible`, `AGENT_MODEL=openai/`, and + `OPENAI_BASE_URL` (plus `OPENAI_API_KEY` only if the server requires auth). + The env-driven base URL is scoped to this profile alone — every other + profile's `base_url` stays fixed in the table, so nothing is silently + re-pointed. Based on @WarGloom's proposal in #7, reworked to the + provider-prefix env convention and a profile-scoped override. + ### Changed - **`/memory/context` ranking — full-content signal + cleaner graph fade.** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a37e15..a9a537a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,9 @@ No other code changes required — the agent resolves model and key through `set ### Self-hosted OpenAI-compatible servers (vLLM, Ollama, llama.cpp) -For a server you run yourself that speaks the OpenAI REST shape, the profile takes an optional third field, `base_url`, and uses LiteLLM's `openai/` prefix to route through the OpenAI-compatible code path: +If you just want to point at *any* OpenAI-compatible `/v1` endpoint without touching code, use the built-in **`openai_compatible`** profile: set `LLM_PROFILE=openai_compatible`, `AGENT_MODEL=openai/`, and `OPENAI_BASE_URL` in your `.env` (plus `OPENAI_API_KEY` if the server needs auth). It takes its base URL from the environment — uniquely; every other profile's `base_url` is fixed in the table below. + +To **bake in** a recurring self-hosted target as its own named profile instead, add a `base_url` field to the entry and use LiteLLM's `openai/` prefix to route through the OpenAI-compatible code path: ```python "vllm_workstation": { diff --git a/README.md b/README.md index 7b95851..6eb48ce 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,24 @@ External databases need the `pg_trgm` and `pgvector` extensions; the connecting ### 4. Pick an LLM provider (for the internal agent) -The agent talks to any LiteLLM-supported backend. **Recommended for new users: `deepinfra` with `google/gemma-4-31B-it`** — fast (5–30s per agent call), cheap, validated end-to-end on the wiki/maintainer/writer pipeline. `nim` is a free-tier fallback (occasionally flaky). The `vllm_*` profiles run a local model on your own GPU workstation — useful for offline / cost-free experiments, but require a running vLLM server reachable from the docker network (typically via SSH tunnel). +The agent talks to any LiteLLM-supported backend. **Recommended for new users: `deepinfra` with `google/gemma-4-31B-it`** — fast (5–30s per agent call), cheap, validated end-to-end on the wiki/maintainer/writer pipeline. `nim` is a free-tier fallback (occasionally flaky). `openai_compatible` points the agent at any OpenAI-compatible `/v1` endpoint (Ollama, LM Studio, copilot-api, a remote vLLM). The `vllm_*` profiles run a local model on your own GPU workstation — useful for offline / cost-free experiments, but require a running vLLM server reachable from the docker network (typically via SSH tunnel). In `.env`: ``` LLM_PROFILE=deepinfra # recommended default DEEPINFRA_API_KEY=... # if profile=deepinfra — get from https://deepinfra.com/ -NVIDIA_NIM_API_KEY=... # if profile=nim — get from https://build.nvidia.com/ +NVIDIA_NIM_API_KEY=... # if profile=nim — get from https://build.nvidia.com/ ``` -Only the key matching your chosen profile needs to be filled. Leave the other blank or absent. +For a generic OpenAI-compatible endpoint (e.g. Ollama): +``` +LLM_PROFILE=openai_compatible +AGENT_MODEL=openai/llama3.2:3b # your served model, openai/ prefix +OPENAI_BASE_URL=http://host.docker.internal:11434/v1 +OPENAI_API_KEY= # blank for local servers without auth +``` + +Only the key matching your chosen profile needs to be filled. Leave the others blank or absent. Adding a third provider (Together, OpenAI, local vLLM, whatever) is a two-line entry in [`braindb/config.py::_LLM_PROFILES`](braindb/config.py) + an env var — no other code changes. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the recipe. @@ -204,16 +212,19 @@ The agent has 21 tools — every single BrainDB endpoint plus `delegate_to_subag - **`deepinfra` — recommended default.** Model `google/gemma-4-31B-it`. Fast (5–30s per agent call), cheap, validated end-to-end. - `nim` — NVIDIA NIM, model `google/gemma-4-31b-it`. Free tier, occasionally flaky. +- `openai_compatible` — any OpenAI-compatible `/v1` endpoint (Ollama, LM Studio, copilot-api, remote vLLM). Set `AGENT_MODEL=openai/` (required — no default) and `OPENAI_BASE_URL`; `OPENAI_API_KEY` is optional for local servers without auth. - `vllm_workstation` / `vllm_workstation_qwen` / `vllm_workstation_gemma` — local vLLM running on your own GPU (advanced / offline; needs the server reachable from the docker network, usually via SSH tunnel). Each profile is a model-prefix + env-var pair; adding a new one is a dict entry. ``` -LLM_PROFILE=deepinfra # or nim / vllm_workstation / vllm_workstation_qwen +LLM_PROFILE=deepinfra # or nim / openai_compatible / vllm_workstation DEEPINFRA_API_KEY=... # required if profile=deepinfra (https://deepinfra.com/) NVIDIA_NIM_API_KEY=... # required if profile=nim (https://build.nvidia.com/) VLLM_API_KEY=... # optional, only if local vLLM is started with --api-key -AGENT_MODEL= # optional: override the profile's default model +OPENAI_BASE_URL=... # required if profile=openai_compatible (the server's /v1 URL) +OPENAI_API_KEY=... # optional, only if that endpoint requires auth +AGENT_MODEL= # optional override; REQUIRED for openai_compatible ``` **Verbose logging**: set `AGENT_VERBOSE=true` in `.env` to log every tool call (entry args + exit elapsed/result) to stdout, visible via `docker logs braindb_api -f`. diff --git a/braindb/config.py b/braindb/config.py index 0e58ead..69874de 100644 --- a/braindb/config.py +++ b/braindb/config.py @@ -20,6 +20,15 @@ "model": "deepinfra/google/gemma-4-31B-it", "api_key_env": "DEEPINFRA_API_KEY", }, + # Generic OpenAI-compatible /v1 endpoint (Ollama, LM Studio, copilot-api, + # vLLM, ...). Unlike the vllm_* profiles, its base_url is NOT fixed here — + # set OPENAI_BASE_URL in the env (see Settings.resolved_base_url). The + # model has no default: set AGENT_MODEL to your served model with the + # openai/ prefix, e.g. AGENT_MODEL=openai/llama3.2:3b. + "openai_compatible": { + "model": "", + "api_key_env": "OPENAI_API_KEY", + }, "vllm_workstation": { "model": "openai/cyankiwi/gemma-4-31B-it-AWQ-4bit", "api_key_env": "VLLM_API_KEY", @@ -117,6 +126,10 @@ class Settings(BaseSettings): # Agent (LiteLLM — provider selected via llm_profile) llm_profile: str = "deepinfra" agent_model: str = "" # blank = use profile's default model + # Base URL for the `openai_compatible` profile ONLY (e.g. Ollama on + # http://host.docker.internal:11434/v1). Ignored by every other profile — + # their base_url stays fixed in _LLM_PROFILES. See resolved_base_url. + openai_base_url: str = "" # Bumped 15 → 20 after live observation on Qwen 27B AWQ-INT4 (vLLM): # deep-research-style runs commonly need >15 tool turns to land # `final_answer`. 20 gives breathing room; finishes-fast providers @@ -188,12 +201,19 @@ def resolved_api_key(self) -> str: key = os.getenv(profile["api_key_env"], "") # Self-hosted profiles (vLLM/Ollama) may run without auth, but the # OpenAI client still needs a non-empty key — supply a placeholder. - if not key and profile.get("base_url"): + # Uses resolved_base_url so the env-driven openai_compatible profile + # is covered too, not just the profiles with a fixed base_url. + if not key and self.resolved_base_url: return "EMPTY" return key @property def resolved_base_url(self) -> str | None: + # Only the generic openai_compatible profile takes its base_url from + # the environment (OPENAI_BASE_URL). Every other profile's base_url + # stays fixed in the _LLM_PROFILES table — no silent global override. + if self.llm_profile == "openai_compatible": + return self.openai_base_url or None return _LLM_PROFILES[self.llm_profile].get("base_url") diff --git a/docker-compose.yml b/docker-compose.yml index 4e62aab..0067848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,9 @@ services: HF_TOKEN: ${HF_TOKEN:-} LLM_PROFILE: ${LLM_PROFILE:-deepinfra} AGENT_MODEL: ${AGENT_MODEL:-} + # OpenAI-compatible profile (Ollama / LM Studio / copilot-api / vLLM): + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} NVIDIA_NIM_API_KEY: ${NVIDIA_NIM_API_KEY:-} DEEPINFRA_API_KEY: ${DEEPINFRA_API_KEY:-} VLLM_API_KEY: ${VLLM_API_KEY:-} diff --git a/tests/test_config_profiles.py b/tests/test_config_profiles.py new file mode 100644 index 0000000..5a7fbc8 --- /dev/null +++ b/tests/test_config_profiles.py @@ -0,0 +1,93 @@ +""" +Pure-unit coverage for LLM provider profile resolution in braindb.config. + +No live stack: these construct Settings() directly with _env_file=None and +monkeypatched env, so they run with `pytest` even when the test stack is down +(they request no `api` fixture). The focus is the `openai_compatible` profile +and — critically — that its env-driven base URL does NOT leak into any other +profile's resolution (the scoping guarantee from PR #7's review). +""" +import pytest + +from braindb.config import Settings + + +def test_openai_compatible_resolves_env_values(monkeypatch): + monkeypatch.setenv("AGENT_MODEL", "openai/gpt-5-mini") + monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:4141/v1") + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + s = Settings(_env_file=None, llm_profile="openai_compatible") + + assert s.resolved_agent_model == "openai/gpt-5-mini" + assert s.resolved_base_url == "http://localhost:4141/v1" + assert s.resolved_api_key == "test-key" + + +def test_openai_compatible_empty_key_falls_back_to_placeholder(monkeypatch): + """Local endpoints (Ollama) run without auth — the OpenAI client still + needs a non-empty key, so the resolver supplies the 'EMPTY' placeholder.""" + monkeypatch.setenv("AGENT_MODEL", "openai/llama3.2:3b") + monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + s = Settings(_env_file=None, llm_profile="openai_compatible") + + assert s.resolved_base_url == "http://localhost:11434/v1" + assert s.resolved_api_key == "EMPTY" + + +def test_openai_compatible_without_base_url_is_none(monkeypatch): + """No OPENAI_BASE_URL → no base URL, and (no key) → real empty key, not + the placeholder (the placeholder is only for self-hosted/base_url paths).""" + monkeypatch.setenv("AGENT_MODEL", "openai/llama3.2:3b") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + s = Settings(_env_file=None, llm_profile="openai_compatible") + + assert s.resolved_base_url is None + assert s.resolved_api_key == "" + + +def test_openai_base_url_does_not_leak_into_other_profiles(monkeypatch): + """The scoping guarantee: OPENAI_BASE_URL is honored ONLY by + openai_compatible. vllm_workstation keeps its table base_url; deepinfra/nim + keep None. A global override would be a regression.""" + monkeypatch.setenv("OPENAI_BASE_URL", "http://evil-override:9999/v1") + + vllm = Settings(_env_file=None, llm_profile="vllm_workstation") + assert vllm.resolved_base_url == "http://host.docker.internal:8002/v1" + + deepinfra = Settings(_env_file=None, llm_profile="deepinfra") + assert deepinfra.resolved_base_url is None + + nim = Settings(_env_file=None, llm_profile="nim") + assert nim.resolved_base_url is None + + +@pytest.mark.parametrize( + ("profile", "expected_model", "expected_base_url"), + [ + ("deepinfra", "deepinfra/google/gemma-4-31B-it", None), + ("nim", "nvidia_nim/google/gemma-4-31b-it", None), + ("vllm_workstation", "openai/cyankiwi/gemma-4-31B-it-AWQ-4bit", + "http://host.docker.internal:8002/v1"), + ], +) +def test_existing_profiles_unchanged(monkeypatch, profile, expected_model, expected_base_url): + """Regression: the established profiles resolve exactly as before.""" + monkeypatch.delenv("AGENT_MODEL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + s = Settings(_env_file=None, llm_profile=profile) + + assert s.resolved_agent_model == expected_model + assert s.resolved_base_url == expected_base_url + + +def test_agent_model_override_still_wins(monkeypatch): + """AGENT_MODEL overrides the profile default for any profile.""" + monkeypatch.setenv("AGENT_MODEL", "deepinfra/some/other-model") + s = Settings(_env_file=None, llm_profile="deepinfra") + assert s.resolved_agent_model == "deepinfra/some/other-model"