Skip to content

Commit a525cc1

Browse files
kevin9327claude
authored 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 768b890 commit a525cc1

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)