Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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=
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model-id>`, 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.**
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model-id>`, 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": {
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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/<model-id>` (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`.
Expand Down
22 changes: 21 additions & 1 deletion braindb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")


Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
93 changes: 93 additions & 0 deletions tests/test_config_profiles.py
Original file line number Diff line number Diff line change
@@ -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"
Loading