Skip to content

Commit aeef169

Browse files
authored
feat(plugins): Ollama support (mpfaffenberger#352)
* Add Ollama model type handler for OpenAI integration * Add docstring to Ollama plugin initialization Adds docstring for the Ollama plugin. * Implement tests for Ollama plugin model handler Add unit tests for the Ollama plugin model type handler, covering various scenarios including custom endpoints, environment variables, and model creation.
1 parent 16c0ff7 commit aeef169

3 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Ollama plugin — registers the 'ollama' model type for local OpenAI Chat Completions-compatible endpoints."""
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Ollama model type handler for OpenAI Chat Completions-compatible endpoints.
2+
3+
Registers the 'ollama' model type so users can connect Code Puppy to local
4+
inference servers (Ollama, LM Studio, vLLM, llama.cpp, etc.) via
5+
~/.code_puppy/extra_models.json.
6+
7+
Minimal config (Ollama on localhost with defaults):
8+
{
9+
"ollama-qwen3": {
10+
"type": "ollama",
11+
"name": "qwen3:30b",
12+
"context_length": 131072
13+
}
14+
}
15+
16+
Full config (remote server, same custom_endpoint format as custom_openai):
17+
{
18+
"lmstudio-codellama": {
19+
"type": "ollama",
20+
"name": "codellama:34b",
21+
"context_length": 16384,
22+
"custom_endpoint": {
23+
"url": "http://192.168.1.50:1234/v1",
24+
"api_key": "$LM_STUDIO_KEY"
25+
}
26+
}
27+
}
28+
29+
Note: Code Puppy requires models with strong tool/function calling support.
30+
Models without tool calling will notwork properly.
31+
"""
32+
33+
import logging
34+
import os
35+
from typing import Any
36+
37+
from pydantic_ai.models.openai import OpenAIChatModel
38+
from pydantic_ai.providers.openai import OpenAIProvider
39+
40+
from code_puppy.callbacks import register_callback
41+
from code_puppy.http_utils import create_async_client
42+
from code_puppy.model_factory import get_custom_config
43+
44+
logger = logging.getLogger(__name__)
45+
46+
# Ollama defaults
47+
_DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1"
48+
_DEFAULT_OLLAMA_API_KEY = "ollama"
49+
50+
51+
def create_ollama_model(
52+
model_name: str,
53+
model_config: dict[str, Any],
54+
config: dict[str, Any],
55+
) -> OpenAIChatModel | None:
56+
"""Create a model for an OpenAI Chat Completions-compatible endpoint.
57+
58+
When ``custom_endpoint`` is present in *model_config*, the standard
59+
``get_custom_config()`` helper is used (same path as ``custom_openai``
60+
and ``codex`` model types).
61+
62+
When ``custom_endpoint`` is absent, sensible Ollama defaults are applied:
63+
- base URL from ``OLLAMA_HOST`` env var, or ``http://localhost:11434/v1``
64+
- api_key ``"ollama"`` (required non-empty by OpenAIProvider, not validated by Ollama)
65+
66+
Args:
67+
model_name: The config key name of the model.
68+
model_config: The model's configuration dict.
69+
config: The full models configuration (unused, kept for API compat).
70+
71+
Returns:
72+
OpenAIChatModel instance, or None if creation fails.
73+
"""
74+
try:
75+
if "custom_endpoint" in model_config:
76+
url, headers, verify, api_key = get_custom_config(model_config)
77+
else:
78+
# Derive base URL: OLLAMA_HOST env var → default
79+
ollama_host = os.environ.get("OLLAMA_HOST", "").rstrip("/")
80+
if ollama_host:
81+
url = (
82+
ollama_host if ollama_host.endswith("/v1") else f"{ollama_host}/v1"
83+
)
84+
else:
85+
url = _DEFAULT_OLLAMA_BASE_URL
86+
headers = {}
87+
verify = None
88+
api_key = _DEFAULT_OLLAMA_API_KEY
89+
90+
client = create_async_client(headers=headers, verify=verify)
91+
92+
provider_args: dict[str, Any] = {
93+
"base_url": url,
94+
"http_client": client,
95+
}
96+
if api_key:
97+
provider_args["api_key"] = api_key
98+
else:
99+
provider_args["api_key"] = _DEFAULT_OLLAMA_API_KEY
100+
101+
provider = OpenAIProvider(**provider_args)
102+
103+
actual_model_name = model_config.get("name", model_name)
104+
model = OpenAIChatModel(actual_model_name, provider=provider)
105+
model.provider = (
106+
provider # Expose for connection-pooling cleanup (project convention)
107+
)
108+
109+
logger.info("Created ollama model: %s -> %s", actual_model_name, url)
110+
return model
111+
112+
except Exception as e:
113+
logger.error("Failed to create ollama model '%s': %s", model_name, e)
114+
return None
115+
116+
117+
def _get_ollama_model_types():
118+
"""Return the ollama model type handler for the register_model_type hook."""
119+
return [
120+
{
121+
"type": "ollama",
122+
"handler": create_ollama_model,
123+
},
124+
]
125+
126+
127+
register_callback("register_model_type", _get_ollama_model_types)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Tests for the Ollama plugin model type handler."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel
7+
8+
from code_puppy.plugins.ollama.register_callbacks import (
9+
_DEFAULT_OLLAMA_API_KEY,
10+
_DEFAULT_OLLAMA_BASE_URL,
11+
_get_ollama_model_types,
12+
create_ollama_model,
13+
)
14+
15+
MODULE = "code_puppy.plugins.ollama.register_callbacks"
16+
17+
18+
@pytest.fixture
19+
def mock_async_client():
20+
with patch(f"{MODULE}.create_async_client") as mock:
21+
mock.return_value = MagicMock()
22+
yield mock
23+
24+
25+
@pytest.fixture
26+
def mock_get_custom_config():
27+
with patch(f"{MODULE}.get_custom_config") as mock:
28+
mock.return_value = (
29+
"http://remote:8080/v1",
30+
{"X-Key": "val"},
31+
None,
32+
"custom-key",
33+
)
34+
yield mock
35+
36+
37+
@pytest.fixture
38+
def mock_provider():
39+
with patch(f"{MODULE}.OpenAIProvider") as mock:
40+
mock.return_value = MagicMock()
41+
yield mock
42+
43+
44+
def test_custom_endpoint_uses_get_custom_config(
45+
mock_async_client, mock_get_custom_config, mock_provider
46+
):
47+
model_config = {
48+
"name": "codellama:34b",
49+
"custom_endpoint": {
50+
"url": "http://remote:8080/v1",
51+
"api_key": "custom-key",
52+
},
53+
}
54+
result = create_ollama_model("my-model", model_config, {})
55+
56+
mock_get_custom_config.assert_called_once_with(model_config)
57+
assert isinstance(result, OpenAIChatModel)
58+
59+
60+
def test_no_custom_endpoint_defaults_to_localhost(
61+
mock_async_client, mock_provider, monkeypatch
62+
):
63+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
64+
model_config = {"name": "llama3:8b"}
65+
result = create_ollama_model("my-model", model_config, {})
66+
67+
assert isinstance(result, OpenAIChatModel)
68+
# Verify the client was created with empty headers and no verify
69+
mock_async_client.assert_called_once_with(headers={}, verify=None)
70+
71+
72+
def test_ollama_host_env_appends_v1(mock_async_client, mock_provider, monkeypatch):
73+
monkeypatch.setenv("OLLAMA_HOST", "http://myserver:11434")
74+
model_config = {"name": "gpt3:30b"}
75+
create_ollama_model("my-model", model_config, {})
76+
77+
# Check the provider was called with /v1 appended
78+
call_kwargs = mock_provider.call_args[1]
79+
assert call_kwargs["base_url"] == "http://myserver:11434/v1"
80+
81+
82+
def test_ollama_host_already_ends_with_v1(
83+
mock_async_client, mock_provider, monkeypatch
84+
):
85+
monkeypatch.setenv("OLLAMA_HOST", "http://myserver:11434/v1")
86+
model_config = {"name": "gpt3:30b"}
87+
create_ollama_model("my-model", model_config, {})
88+
89+
call_kwargs = mock_provider.call_args[1]
90+
assert call_kwargs["base_url"] == "http://myserver:11434/v1"
91+
92+
93+
def test_ollama_host_trailing_slash_stripped(
94+
mock_async_client, mock_provider, monkeypatch
95+
):
96+
monkeypatch.setenv("OLLAMA_HOST", "http://myserver:11434/")
97+
model_config = {"name": "gpt3:30b"}
98+
create_ollama_model("my-model", model_config, {})
99+
100+
call_kwargs = mock_provider.call_args[1]
101+
assert call_kwargs["base_url"] == "http://myserver:11434/v1"
102+
103+
104+
def test_ollama_host_empty_string_uses_default(
105+
mock_async_client, mock_provider, monkeypatch
106+
):
107+
monkeypatch.setenv("OLLAMA_HOST", "")
108+
model_config = {"name": "llama3:8b"}
109+
create_ollama_model("my-model", model_config, {})
110+
111+
call_kwargs = mock_provider.call_args[1]
112+
assert call_kwargs["base_url"] == _DEFAULT_OLLAMA_BASE_URL
113+
114+
115+
def test_returns_open_ai_chat_model_not_responses(
116+
mock_async_client, mock_provider, monkeypatch
117+
):
118+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
119+
model_config = {"name": "llama3:8b"}
120+
result = create_ollama_model("my-model", model_config, {})
121+
122+
assert isinstance(result, OpenAIChatModel)
123+
assert not isinstance(result, OpenAIResponsesModel)
124+
125+
126+
def test_provider_is_set_on_model(mock_async_client, mock_provider, monkeypatch):
127+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
128+
model_config = {"name": "llama3:8b"}
129+
result = create_ollama_model("my-model", model_config, {})
130+
131+
assert result is not None
132+
assert hasattr(result, "provider")
133+
assert result.provider is not None
134+
135+
136+
def test_uses_model_config_name(mock_async_client, mock_provider, monkeypatch):
137+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
138+
model_config = {"name": "gpt3:30b"}
139+
result = create_ollama_model("config-key", model_config, {})
140+
141+
assert result is not None
142+
assert result.model_name == "gpt3:30b"
143+
144+
145+
def test_falls_back_to_model_name_key(mock_async_client, mock_provider, monkeypatch):
146+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
147+
model_config = {} # No "name" key
148+
result = create_ollama_model("fallback-name", model_config, {})
149+
150+
assert result is not None
151+
assert result.model_name == "fallback-name"
152+
153+
154+
def test_returns_none_on_exception(monkeypatch):
155+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
156+
model_config = {
157+
"name": "test",
158+
"custom_endpoint": {"url": "http://x", "api_key": "k"},
159+
}
160+
with patch(f"{MODULE}.get_custom_config", side_effect=RuntimeError("boom")):
161+
result = create_ollama_model("bad-model", model_config, {})
162+
assert result is None
163+
164+
165+
def test_get_ollama_model_types_structure():
166+
result = _get_ollama_model_types()
167+
assert isinstance(result, list)
168+
assert len(result) == 1
169+
entry = result[0]
170+
assert entry["type"] == "ollama"
171+
assert callable(entry["handler"])
172+
assert entry["handler"] is create_ollama_model
173+
174+
175+
def test_api_key_defaults_to_ollama(mock_async_client, mock_provider, monkeypatch):
176+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
177+
model_config = {"name": "llama3:8b"}
178+
create_ollama_model("my-model", model_config, {})
179+
180+
call_kwargs = mock_provider.call_args[1]
181+
assert call_kwargs["api_key"] == _DEFAULT_OLLAMA_API_KEY
182+
183+
184+
def test_api_key_fallback_when_custom_returns_none(mock_async_client, mock_provider):
185+
with patch(f"{MODULE}.get_custom_config") as mock_gcc:
186+
mock_gcc.return_value = ("http://x/v1", {}, None, None)
187+
create_ollama_model(
188+
"m",
189+
{"name": "t", "custom_endpoint": {"url": "http://x/v1"}},
190+
{},
191+
)
192+
193+
call_kwargs = mock_provider.call_args[1]
194+
assert call_kwargs["api_key"] == _DEFAULT_OLLAMA_API_KEY
195+
196+
197+
def test_default_constants():
198+
assert _DEFAULT_OLLAMA_BASE_URL == "http://localhost:11434/v1"
199+
assert _DEFAULT_OLLAMA_API_KEY == "ollama"

0 commit comments

Comments
 (0)