Skip to content

Commit 0250331

Browse files
authored
Merge pull request #48 from VibePod/issue-11
Add LLM server integration for connecting agents (claude, codex) to Ollama, vLLM, and other providers
2 parents e01acab + d36daa2 commit 0250331

9 files changed

Lines changed: 531 additions & 0 deletions

File tree

docs/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ agents:
8585
volumes: []
8686
init: []
8787

88+
# Connect agents to a local or remote LLM server (Ollama, vLLM, etc.)
89+
llm:
90+
enabled: false
91+
base_url: "" # Server endpoint URL
92+
api_key: "" # Auth token (set to "ollama" for Ollama)
93+
model: "" # Model name passed to the agent
94+
8895
logging:
8996
enabled: true
9097
image: vibepod/datasette:latest
@@ -111,6 +118,10 @@ These variables override the corresponding config keys without editing any file:
111118
| `VP_NO_COLOR` | `no_color` | `VP_NO_COLOR=true` |
112119
| `VP_DATASETTE_PORT` | `logging.ui_port` | `VP_DATASETTE_PORT=9001` |
113120
| `VP_PROXY_ENABLED` | `proxy.enabled` | `VP_PROXY_ENABLED=false` |
121+
| `VP_LLM_ENABLED` | `llm.enabled` | `VP_LLM_ENABLED=true` |
122+
| `VP_LLM_BASE_URL` | `llm.base_url` | `VP_LLM_BASE_URL=http://localhost:11434` |
123+
| `VP_LLM_API_KEY` | `llm.api_key` | `VP_LLM_API_KEY=ollama` |
124+
| `VP_LLM_MODEL` | `llm.model` | `VP_LLM_MODEL=qwen3:14b` |
114125
| `VP_CONFIG_DIR` | *(config root)* | `VP_CONFIG_DIR=/custom/path` |
115126

116127
### Image overrides

docs/llm.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Using OSS Models via Ollama, vLLM, and other LLM servers
2+
3+
VibePod can connect agents to external LLM servers that expose OpenAI- or Anthropic-compatible APIs. This lets you run agents like Claude Code and Codex against open-source models served by [Ollama](https://ollama.com), [vLLM](https://docs.vllm.ai), or any compatible endpoint.
4+
5+
## Supported agents
6+
7+
| Agent | Env vars injected | CLI flags appended |
8+
|-------|------------------|--------------------|
9+
| claude | `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY` | `--model <model>` |
10+
| codex | `CODEX_OSS_BASE_URL` | `--oss -m <model>` |
11+
12+
Other agents do not yet have LLM mapping and will not receive any LLM configuration.
13+
14+
## Quick start with Ollama
15+
16+
### 1. Start Ollama and pull a model
17+
18+
```bash
19+
ollama pull qwen3:14b
20+
```
21+
22+
### 2. Configure VibePod
23+
24+
Add the following to your global or project config:
25+
26+
```yaml
27+
# ~/.config/vibepod/config.yaml
28+
llm:
29+
enabled: true
30+
base_url: "http://host.docker.internal:11434"
31+
api_key: "ollama"
32+
model: "qwen3:14b"
33+
```
34+
35+
!!! note
36+
Use `host.docker.internal` (not `localhost`) so the Docker container can reach Ollama on the host machine.
37+
38+
### 3. Run an agent
39+
40+
```bash
41+
vp run claude
42+
# Starts Claude Code with:
43+
# ANTHROPIC_BASE_URL=http://host.docker.internal:11434
44+
# ANTHROPIC_API_KEY=ollama
45+
# claude --model qwen3:14b
46+
47+
vp run codex
48+
# Starts Codex with:
49+
# CODEX_OSS_BASE_URL=http://host.docker.internal:11434
50+
# codex --oss -m qwen3:14b
51+
```
52+
53+
## Using environment variables
54+
55+
You can also configure LLM settings at runtime without editing config files.
56+
57+
**Claude Code with a remote Ollama server:**
58+
59+
```bash
60+
VP_LLM_ENABLED=true VP_LLM_MODEL=qwen3.5:9b VP_LLM_BASE_URL=https://ollama.example.com vp run claude
61+
```
62+
63+
**Codex with a remote Ollama server (note the `/v1` suffix):**
64+
65+
```bash
66+
VP_LLM_ENABLED=true VP_LLM_MODEL=qwen3.5:9b VP_LLM_BASE_URL=https://ollama.example.com/v1 vp run codex
67+
```
68+
69+
**Local Ollama with an API key:**
70+
71+
```bash
72+
VP_LLM_ENABLED=true VP_LLM_BASE_URL=http://host.docker.internal:11434 VP_LLM_API_KEY=ollama VP_LLM_MODEL=qwen3:14b vp run claude
73+
```
74+
75+
!!! note
76+
Claude Code uses the Anthropic-compatible endpoint (no `/v1` suffix), while Codex uses the OpenAI-compatible endpoint (with `/v1` suffix). Adjust `VP_LLM_BASE_URL` accordingly, or use per-agent overrides if you need both agents to work from the same config.
77+
78+
See [Configuration > Environment variables](configuration.md#environment-variables) for the full list.
79+
80+
## Using vLLM or other OpenAI-compatible servers
81+
82+
Point `base_url` at any server that speaks the OpenAI or Anthropic API:
83+
84+
```yaml
85+
llm:
86+
enabled: true
87+
base_url: "http://my-vllm-server:8000/v1"
88+
api_key: "my-api-key"
89+
model: "meta-llama/Llama-3-8B-Instruct"
90+
```
91+
92+
## Per-agent overrides
93+
94+
If you need different LLM settings for a specific agent, use the per-agent `env` config. Per-agent env vars take precedence over the `llm` section:
95+
96+
```yaml
97+
llm:
98+
enabled: true
99+
base_url: "http://host.docker.internal:11434"
100+
api_key: "ollama"
101+
model: "qwen3:14b"
102+
103+
agents:
104+
claude:
105+
env:
106+
ANTHROPIC_BASE_URL: "http://different-server:11434"
107+
```
108+
109+
## Disabling
110+
111+
To turn off LLM injection without removing the config:
112+
113+
```yaml
114+
llm:
115+
enabled: false
116+
```
117+
118+
Or at runtime:
119+
120+
```bash
121+
VP_LLM_ENABLED=false vp run claude
122+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ nav:
5353
- Development: development.md
5454
- Agents: agents/index.md
5555
- Configuration: configuration.md
56+
- LLM Integration: llm.md
5657
- CLI Reference: cli-reference.md

src/vibepod/commands/run.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,22 @@ def run(
265265
**_parse_env_pairs(env or []),
266266
}
267267

268+
llm_cfg = config.get("llm", {})
269+
llm_command_extra: list[str] = []
270+
if llm_cfg.get("enabled") and spec.llm_env_map:
271+
llm_values = {
272+
"base_url": str(llm_cfg.get("base_url", "")).strip(),
273+
"api_key": str(llm_cfg.get("api_key", "")).strip(),
274+
"model": str(llm_cfg.get("model", "")).strip(),
275+
}
276+
for key, env_var in spec.llm_env_map.items():
277+
value = llm_values.get(key, "")
278+
if value:
279+
merged_env.setdefault(env_var, value)
280+
llm_model = llm_values["model"]
281+
if llm_model and spec.llm_model_args:
282+
llm_command_extra = [*spec.llm_model_args, llm_model]
283+
268284
image = effective_agent_image(selected_agent, config)
269285

270286
try:
@@ -304,6 +320,9 @@ def run(
304320
else:
305321
warning(f"IKWID mode not supported for agent '{selected_agent}', ignoring")
306322

323+
if llm_command_extra:
324+
command = list(command or []) + llm_command_extra
325+
307326
config_dir = agent_config_dir(selected_agent)
308327
config_dir.mkdir(parents=True, exist_ok=True)
309328

src/vibepod/core/agents.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class AgentSpec:
2222
platform: str | None = None
2323
run_as_host_user: bool = False
2424
ikwid_args: list[str] | None = None
25+
llm_env_map: dict[str, str] | None = None
26+
llm_model_args: list[str] | None = None
2527

2628

2729
AGENT_SPECS: dict[str, AgentSpec] = {
@@ -34,6 +36,8 @@ class AgentSpec:
3436
"/claude",
3537
{"CLAUDE_CONFIG_DIR": "/claude"},
3638
ikwid_args=["--dangerously-skip-permissions"],
39+
llm_env_map={"base_url": "ANTHROPIC_BASE_URL", "api_key": "ANTHROPIC_API_KEY"},
40+
llm_model_args=["--model"],
3741
),
3842
"gemini": AgentSpec(
3943
"gemini",
@@ -91,6 +95,10 @@ class AgentSpec:
9195
"/config",
9296
{"HOME": "/config"},
9397
ikwid_args=["--full-auto"],
98+
llm_env_map={
99+
"base_url": "CODEX_OSS_BASE_URL",
100+
},
101+
llm_model_args=["--oss", "-m"],
94102
),
95103
}
96104

src/vibepod/core/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ def _default_config() -> dict[str, Any]:
105105
"ca_dir": str(config_root / "proxy" / "mitmproxy"),
106106
"ca_path": str(config_root / "proxy" / "mitmproxy" / "mitmproxy-ca-cert.pem"),
107107
},
108+
"llm": {
109+
"enabled": False,
110+
"base_url": "",
111+
"api_key": "",
112+
"model": "",
113+
},
108114
"aliases": DEFAULT_ALIASES.copy(),
109115
}
110116

@@ -146,6 +152,10 @@ def _apply_env(config: dict[str, Any]) -> dict[str, Any]:
146152
"VP_NO_COLOR": ("no_color", lambda x: x.lower() == "true"),
147153
"VP_DATASETTE_PORT": ("logging.ui_port", int),
148154
"VP_PROXY_ENABLED": ("proxy.enabled", lambda x: x.lower() == "true"),
155+
"VP_LLM_ENABLED": ("llm.enabled", lambda x: x.lower() == "true"),
156+
"VP_LLM_BASE_URL": ("llm.base_url", str),
157+
"VP_LLM_API_KEY": ("llm.api_key", str),
158+
"VP_LLM_MODEL": ("llm.model", str),
149159
}
150160

151161
for env_key, (config_path, converter) in mappings.items():

tests/test_agents.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,26 @@ def test_get_agent_shortcut_known_agent() -> None:
7777
assert get_agent_shortcut(agent) == expected_by_agent[agent]
7878
assert get_agent_shortcut(f" {agent.upper()} ") == expected_by_agent[agent]
7979
assert get_agent_shortcut("unknown") is None
80+
81+
82+
def test_claude_spec_has_llm_env_map() -> None:
83+
spec = get_agent_spec("claude")
84+
assert spec.llm_env_map == {
85+
"base_url": "ANTHROPIC_BASE_URL",
86+
"api_key": "ANTHROPIC_API_KEY",
87+
}
88+
assert spec.llm_model_args == ["--model"]
89+
90+
91+
def test_codex_spec_has_llm_env_map() -> None:
92+
spec = get_agent_spec("codex")
93+
assert spec.llm_env_map == {
94+
"base_url": "CODEX_OSS_BASE_URL",
95+
}
96+
assert spec.llm_model_args == ["--oss", "-m"]
97+
98+
99+
def test_agents_without_llm_env_map() -> None:
100+
for agent in ("gemini", "opencode", "devstral", "auggie", "copilot"):
101+
spec = get_agent_spec(agent)
102+
assert spec.llm_env_map is None, f"{agent} should not have llm_env_map"

tests/test_config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,28 @@ def test_per_agent_auto_pull_override(monkeypatch, tmp_path: Path) -> None:
138138
assert config["agents"]["claude"]["auto_pull"] is True
139139
# Other agents should still have None (unset)
140140
assert config["agents"]["gemini"]["auto_pull"] is None
141+
142+
143+
def test_default_config_includes_llm_section(monkeypatch, tmp_path: Path) -> None:
144+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path))
145+
config = get_config()
146+
llm = config.get("llm")
147+
assert isinstance(llm, dict)
148+
assert llm["enabled"] is False
149+
assert llm["base_url"] == ""
150+
assert llm["api_key"] == ""
151+
assert llm["model"] == ""
152+
153+
154+
def test_llm_env_overrides(monkeypatch, tmp_path: Path) -> None:
155+
monkeypatch.setenv("VP_CONFIG_DIR", str(tmp_path))
156+
monkeypatch.setenv("VP_LLM_ENABLED", "true")
157+
monkeypatch.setenv("VP_LLM_BASE_URL", "http://localhost:11434/v1")
158+
monkeypatch.setenv("VP_LLM_API_KEY", "sk-test")
159+
monkeypatch.setenv("VP_LLM_MODEL", "llama3")
160+
config = get_config()
161+
llm = config["llm"]
162+
assert llm["enabled"] is True
163+
assert llm["base_url"] == "http://localhost:11434/v1"
164+
assert llm["api_key"] == "sk-test"
165+
assert llm["model"] == "llama3"

0 commit comments

Comments
 (0)