Skip to content

Commit aef0799

Browse files
kevin9327claude
andcommitted
Start the Pydantic AI Bot on an Ollama model named with its tag
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 2d09a08 commit aef0799

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
@@ -297,6 +297,13 @@ jobs:
297297
. .venv-llamaindex/bin/activate
298298
python -m pip install --requirement agent-llamaindex/requirements.txt --requirement agent-llamaindex/requirements-test.txt
299299
python -m pytest agent-llamaindex/tests -q
300+
- name: Pydantic AI model-choice regression
301+
run: |
302+
set -euo pipefail
303+
python -m venv .venv-pydantic-ai
304+
. .venv-pydantic-ai/bin/activate
305+
python -m pip install --requirement agent-pydantic-ai/requirements.txt --requirement agent-pydantic-ai/requirements-test.txt
306+
python -m pytest agent-pydantic-ai/tests -q
300307
- run: bun install --frozen-lockfile
301308
- run: bun test tests/compose.test.ts
302309
- 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
@@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### The Pydantic AI Bot starts on an Ollama model named with its tag
12+
13+
Ollama names every model with a tag after a colon, as in `llama3.1:8b`, and that is the name the
14+
setup screen passes on for an OpenAI-compatible endpoint. The Pydantic AI Bot took any colon in the
15+
model's name to mean the name already carried a provider, so Pydantic AI read `llama3.1` as one,
16+
refused it as unknown, and the Bot never started. A model's name now carries a provider only when it
17+
begins with the chosen provider's own, as in `anthropic:claude-sonnet-4-5`.
18+
1119
## 0.0.13
1220

1321
### 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)