diff --git a/CHANGELOG.md b/CHANGELOG.md index af2dece18..613bd688e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,14 @@ plan sign-in. Retrying a failed summary refreshes its sidebar preview without re activity. The macOS app includes the microphone permission description and audio-input entitlement needed for dictation and voice calls. +### The Pydantic AI Bot answers on a plain OpenAI key or an Anthropic key + +Picked with either key, the Pydantic AI Bot failed every run. Compose writes the endpoint the choice +did not need as empty (`OPENAI_BASE_URL` for a plain OpenAI key, `ANTHROPIC_BASE_URL` for an +Anthropic key), and Pydantic AI builds each provider's client from the environment, so the SDK was +given "" as the address. The Bot now removes an empty value before building the model, as +`agent-langgraph-agui` already does, so the SDK uses its own endpoint. A real endpoint is unchanged. + ### Find older conversations and keep chat preferences across devices The sidebar loads older conversations as the person scrolls. Settings save the choice to emphasize diff --git a/agent-pydantic-ai/src/main.py b/agent-pydantic-ai/src/main.py index 6c6050649..e46fdc176 100644 --- a/agent-pydantic-ai/src/main.py +++ b/agent-pydantic-ai/src/main.py @@ -26,6 +26,26 @@ def _model_id() -> str: return model if model.startswith(f"{provider}:") else f"{provider}:{model}" +def _drop_blank_base_urls() -> None: + """Compose exports missing overrides as ""; the SDKs only default an absent URL. + + The model screen writes the endpoint it did not need as empty: `OPENAI_BASE_URL` for a plain + OpenAI key, `ANTHROPIC_BASE_URL` for an Anthropic key. Pydantic AI builds each provider's client + from the environment, so the empty value reached the SDK as the address and every run failed to + connect. Taking it out lets the SDK use its own endpoint; a real one is left as it is. The same + rule as `_normalize_openai_base_url` in `agent-langgraph-agui`. + """ + for name in ("OPENAI_BASE_URL", "ANTHROPIC_BASE_URL"): + value = os.environ.get(name) + if value is None: + continue + if value.strip(): + os.environ[name] = value.strip() + else: + os.environ.pop(name, None) + + +_drop_blank_base_urls() agent = Agent(_model_id()) app = FastAPI() diff --git a/agent-pydantic-ai/tests/test_main.py b/agent-pydantic-ai/tests/test_main.py index d9de661a5..822d32b77 100644 --- a/agent-pydantic-ai/tests/test_main.py +++ b/agent-pydantic-ai/tests/test_main.py @@ -6,6 +6,8 @@ import time from pathlib import Path +import httpx +import httpx2 import pytest import uvicorn from fastapi import FastAPI, Request @@ -201,3 +203,61 @@ def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, c assert '"RUN_ERROR"' not in response.text assert "hello" in response.text assert seen == [expected] + + +# What Compose passes for the two plain keys: the key, and the endpoint it did not need as "". +BLANK_URLS = { + "an OpenAI key": ( + {"BOT_PROVIDER": "openai", "BOT_MODEL": "gpt-5.5", "OPENAI_API_KEY": "test-key", "ANTHROPIC_API_KEY": ""}, + ("https", "api.openai.com", "/v1/responses"), + ("openai", "gpt-5.5"), + ), + "an Anthropic key": ( + {"BOT_PROVIDER": "anthropic", "BOT_MODEL": "claude-sonnet-4-5", "ANTHROPIC_API_KEY": "test-key", "OPENAI_API_KEY": ""}, + ("https", "api.anthropic.com", "/v1/messages"), + ("anthropic", "claude-sonnet-4-5"), + ), +} + + +@pytest.mark.parametrize("choice", list(BLANK_URLS)) +def test_a_key_reaches_the_official_endpoint_when_compose_leaves_the_url_blank(monkeypatch, choice): + environment, destination, expected = BLANK_URLS[choice] + seen = [] + provider_seen = [] + provider_app = _provider_app(provider_seen) + + def respond_with(module): + async def respond(transport, request): + seen.append((request.url.scheme, request.url.host, request.url.path)) + async with module.ASGITransport(app=provider_app) as local_provider: + return await local_provider.handle_async_request(request) + + return respond + + # Keep the real Pydantic AI and SDK clients; replace only the network transport, on both HTTP + # stacks, because the OpenAI SDK sends through httpx2 rather than httpx. + monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", respond_with(httpx)) + monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", respond_with(httpx2)) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("PYDANTIC_AI_NO_BANNER", "1") + monkeypatch.setenv("OPENAI_BASE_URL", "") + monkeypatch.setenv("ANTHROPIC_BASE_URL", "") + for key, value in environment.items(): + monkeypatch.setenv(key, value) + + from src import main + + main = importlib.reload(main) + response = TestClient(main.app).post( + "/", json=RUN, headers={"x-openbot-agent-token": TOKEN} + ) + + assert seen == [destination] + assert provider_seen == [expected] + assert response.status_code == 200 + assert '"RUN_FINISHED"' in response.text + assert '"RUN_ERROR"' not in response.text + assert "hello" in response.text