From 792fc6ac603c70e681bad01d853c650017506673 Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Mon, 28 Sep 2026 15:43:29 +0300 Subject: [PATCH] Answer on a plain OpenAI key or an Anthropic key from the Pydantic AI Bot Compose writes the endpoint a model choice does 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, and the SDKs only default an absent URL, so both choices failed every run. The Bot now removes an empty value before building the model, as agent-langgraph-agui already does. --- CHANGELOG.md | 8 ++++ agent-pydantic-ai/src/main.py | 20 ++++++++++ agent-pydantic-ai/tests/test_main.py | 60 ++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) 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