Skip to content

Commit 4d1a514

Browse files
kevin9327claude
andauthored
Start the Pydantic AI Bot on an Ollama model named with its tag (#610)
The Pydantic AI Bot builds its model id as `provider:model`, and took `BOT_MODEL` as already carrying a provider whenever it held a colon. Ollama puts a colon in every model's name: `ollama list` shows `llama3.1:8b`, `qwen3:8b`, `mistral:7b`, and that is the name the setup screen passes on for an OpenAI-compatible endpoint. So the harness handed `llama3.1:8b` to Pydantic AI verbatim, Pydantic AI read `llama3.1` as the provider and refused it, and the module failed at import with `UserError: Unknown model: llama3.1:8b`: the container never served a run. `mistral:7b` is worse by one step, being taken as the Mistral provider and failing for want of its package. A model's name now carries a provider only when it begins with the chosen provider's own name and a colon, so `anthropic:claude-sonnet-4-5` still reaches Anthropic as written and anything else is prefixed with the provider. Pydantic AI splits on the first colon, so `openai:llama3.1:8b` reaches the endpoint as `llama3.1:8b`. The test follows the LlamaIndex Bot's, with the Responses API faked because that is the route `openai:` takes in Pydantic AI. Before this change the two tagged models fail and the other four choices pass. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 768b890 commit 4d1a514

5 files changed

Lines changed: 227 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ jobs:
304304
. .venv-agno/bin/activate
305305
python -m pip install --requirement agent-agno/requirements.txt --requirement agent-agno/requirements-test.txt
306306
python -m pytest agent-agno/tests -q
307+
- name: Pydantic AI model-choice regression
308+
run: |
309+
set -euo pipefail
310+
python -m venv .venv-pydantic-ai
311+
. .venv-pydantic-ai/bin/activate
312+
python -m pip install --requirement agent-pydantic-ai/requirements.txt --requirement agent-pydantic-ai/requirements-test.txt
313+
python -m pytest agent-pydantic-ai/tests -q
307314
- run: bun install --frozen-lockfile
308315
- run: bun test tests/compose.test.ts
309316
- run: docker compose --env-file /dev/null --profile harness config --format json >/dev/null

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ Compose passes when the setup screen names none, so the run ended in `Unsupporte
1616
parameter the model does not take is now dropped instead, the way the LlamaIndex Bot already does
1717
it. The Anthropic key and an OpenAI-compatible endpoint behave as before.
1818

19+
### The Pydantic AI Bot starts on an Ollama model named with its tag
20+
21+
Ollama names every model with a tag after a colon, as in `llama3.1:8b`, and that is the name the
22+
setup screen passes on for an OpenAI-compatible endpoint. The Pydantic AI Bot took any colon in the
23+
model's name to mean the name already carried a provider, so Pydantic AI read `llama3.1` as one,
24+
refused it as unknown, and the Bot never started. A model's name now carries a provider only when it
25+
begins with the chosen provider's own, as in `anthropic:claude-sonnet-4-5`.
26+
1927
## 0.0.13
2028

2129
### Fresh desktop setup installs its runtime before sign-in
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
httpx==0.28.1
2+
pytest==9.0.2

agent-pydantic-ai/src/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515

1616

1717
def _model_id() -> str:
18-
"""`provider:model`, which is the form Pydantic AI names a model in."""
18+
"""`provider:model`, which is the form Pydantic AI names a model in.
19+
20+
A colon in `BOT_MODEL` names the provider only when it follows the provider's own name. Any
21+
other colon is part of the model's name: Ollama tags every model with one, as in `llama3.1:8b`,
22+
and Pydantic AI read the part before it as a provider, refused an unknown one and started no Bot.
23+
"""
1924
provider = (os.environ.get("BOT_PROVIDER") or "openai").strip()
2025
model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
21-
return model if ":" in model else f"{provider}:{model}"
26+
return model if model.startswith(f"{provider}:") else f"{provider}:{model}"
2227

2328

2429
agent = Agent(_model_id())
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import importlib
2+
import json
3+
import socket
4+
import sys
5+
import threading
6+
import time
7+
from pathlib import Path
8+
9+
import pytest
10+
import uvicorn
11+
from fastapi import FastAPI, Request
12+
from fastapi.responses import StreamingResponse
13+
from fastapi.testclient import TestClient
14+
15+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16+
17+
TOKEN = "test-token"
18+
RUN = {
19+
"threadId": "thread-1",
20+
"runId": "run-1",
21+
"state": {},
22+
"messages": [{"id": "m1", "role": "user", "content": "Say hello"}],
23+
"tools": [],
24+
"context": [],
25+
"forwardedProps": {},
26+
}
27+
28+
29+
def _sse(events):
30+
async def stream():
31+
for name, data in events:
32+
yield f"event: {name}\ndata: {json.dumps(data)}\n\n"
33+
34+
return StreamingResponse(stream(), media_type="text/event-stream")
35+
36+
37+
def _provider_app(seen):
38+
app = FastAPI()
39+
40+
# `openai:` is the Responses API in Pydantic AI, so that is the route an OpenAI key and an
41+
# OpenAI-compatible endpoint both reach.
42+
@app.post("/v1/responses")
43+
async def openai_responses(request: Request):
44+
body = await request.json()
45+
seen.append(("openai", body["model"]))
46+
response = {
47+
"id": "resp",
48+
"object": "response",
49+
"created_at": 0,
50+
"model": body["model"],
51+
"status": "in_progress",
52+
"output": [],
53+
"parallel_tool_calls": True,
54+
"tool_choice": "auto",
55+
"tools": [],
56+
}
57+
item = {"id": "msg", "type": "message", "role": "assistant", "status": "completed"}
58+
text = {"type": "output_text", "text": "hello", "annotations": []}
59+
done = {
60+
**response,
61+
"status": "completed",
62+
"output": [{**item, "content": [text]}],
63+
"usage": {
64+
"input_tokens": 1,
65+
"input_tokens_details": {"cached_tokens": 0},
66+
"output_tokens": 1,
67+
"output_tokens_details": {"reasoning_tokens": 0},
68+
"total_tokens": 2,
69+
},
70+
}
71+
return _sse(
72+
[
73+
("response.created", {"type": "response.created", "sequence_number": 0, "response": response}),
74+
("response.output_item.added", {"type": "response.output_item.added", "sequence_number": 1, "output_index": 0, "item": {**item, "status": "in_progress", "content": []}}),
75+
("response.output_text.delta", {"type": "response.output_text.delta", "sequence_number": 2, "item_id": "msg", "output_index": 0, "content_index": 0, "delta": "hello", "logprobs": []}),
76+
("response.output_item.done", {"type": "response.output_item.done", "sequence_number": 3, "output_index": 0, "item": {**item, "content": [text]}}),
77+
("response.completed", {"type": "response.completed", "sequence_number": 4, "response": done}),
78+
]
79+
)
80+
81+
@app.post("/v1/messages")
82+
async def anthropic_messages(request: Request):
83+
body = await request.json()
84+
seen.append(("anthropic", body["model"]))
85+
message = {
86+
"id": "msg",
87+
"type": "message",
88+
"role": "assistant",
89+
"model": body["model"],
90+
"stop_sequence": None,
91+
}
92+
return _sse(
93+
[
94+
("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
95+
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
96+
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}),
97+
("content_block_stop", {"type": "content_block_stop", "index": 0}),
98+
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}),
99+
("message_stop", {"type": "message_stop"}),
100+
]
101+
)
102+
103+
return app
104+
105+
106+
@pytest.fixture
107+
def provider():
108+
seen = []
109+
with socket.socket() as probe:
110+
probe.bind(("127.0.0.1", 0))
111+
port = probe.getsockname()[1]
112+
server = uvicorn.Server(
113+
uvicorn.Config(_provider_app(seen), host="127.0.0.1", port=port, log_level="error")
114+
)
115+
thread = threading.Thread(target=server.run, daemon=True)
116+
thread.start()
117+
deadline = time.monotonic() + 10
118+
while not server.started and time.monotonic() < deadline:
119+
time.sleep(0.01)
120+
yield f"http://127.0.0.1:{port}", seen
121+
server.should_exit = True
122+
thread.join(timeout=10)
123+
124+
125+
def _endpoint(model):
126+
"""What the desktop writes for an OpenAI-compatible endpoint serving `model`."""
127+
return (
128+
lambda base: {
129+
"BOT_PROVIDER": "",
130+
"BOT_MODEL": model,
131+
"OPENAI_API_KEY": "no-key-needed",
132+
"OPENAI_BASE_URL": f"{base}/v1",
133+
"ANTHROPIC_API_KEY": "",
134+
},
135+
("openai", model),
136+
)
137+
138+
139+
CHOICES = {
140+
"an Anthropic key": (
141+
lambda base: {
142+
"BOT_PROVIDER": "anthropic",
143+
"BOT_MODEL": "claude-sonnet-4-5",
144+
"ANTHROPIC_API_KEY": "test-key",
145+
"ANTHROPIC_BASE_URL": base,
146+
"OPENAI_API_KEY": "",
147+
"OPENAI_BASE_URL": "",
148+
},
149+
("anthropic", "claude-sonnet-4-5"),
150+
),
151+
# Written the way Pydantic AI names a model, which still reaches that provider.
152+
"a model already named with its provider": (
153+
lambda base: {
154+
"BOT_PROVIDER": "anthropic",
155+
"BOT_MODEL": "anthropic:claude-sonnet-4-5",
156+
"ANTHROPIC_API_KEY": "test-key",
157+
"ANTHROPIC_BASE_URL": base,
158+
"OPENAI_API_KEY": "",
159+
"OPENAI_BASE_URL": "",
160+
},
161+
("anthropic", "claude-sonnet-4-5"),
162+
),
163+
"an OpenAI-compatible endpoint": _endpoint("local-model"),
164+
# Ollama names every model with its tag after a colon, which is the separator Pydantic AI puts
165+
# between a provider and a model.
166+
"an Ollama model named with its tag": _endpoint("llama3.1:8b"),
167+
# And a tag on a model that shares its name with a provider Pydantic AI knows.
168+
"an Ollama model named like a provider": _endpoint("mistral:7b"),
169+
"an OpenAI key": (
170+
lambda base: {
171+
"BOT_PROVIDER": "",
172+
"BOT_MODEL": "gpt-5.5",
173+
"OPENAI_API_KEY": "test-key",
174+
"OPENAI_BASE_URL": f"{base}/v1",
175+
"ANTHROPIC_API_KEY": "",
176+
},
177+
("openai", "gpt-5.5"),
178+
),
179+
}
180+
181+
182+
@pytest.mark.parametrize("choice", list(CHOICES))
183+
def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, choice):
184+
base, seen = provider
185+
environment, expected = CHOICES[choice]
186+
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
187+
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
188+
monkeypatch.setenv("PYDANTIC_AI_NO_BANNER", "1")
189+
for key, value in environment(base).items():
190+
monkeypatch.setenv(key, value)
191+
192+
from src import main
193+
194+
main = importlib.reload(main)
195+
response = TestClient(main.app).post(
196+
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
197+
)
198+
199+
assert response.status_code == 200
200+
assert '"RUN_FINISHED"' in response.text
201+
assert '"RUN_ERROR"' not in response.text
202+
assert "hello" in response.text
203+
assert seen == [expected]

0 commit comments

Comments
 (0)