diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f233476a..e7578bb3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,6 +311,13 @@ jobs: . .venv-pydantic-ai/bin/activate python -m pip install --requirement agent-pydantic-ai/requirements.txt --requirement agent-pydantic-ai/requirements-test.txt python -m pytest agent-pydantic-ai/tests -q + - name: Langroid model-choice regression + run: | + set -euo pipefail + python -m venv .venv-langroid + . .venv-langroid/bin/activate + python -m pip install --requirement agent-langroid/requirements.txt --requirement agent-langroid/requirements-test.txt + python -m pytest agent-langroid/tests -q - run: bun install --frozen-lockfile - run: bun test tests/compose.test.ts - run: docker compose --env-file /dev/null --profile harness config --format json >/dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md index 498d8881e..862204d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ model's name to mean the name already carried a provider, so Pydantic AI read `l refused it as unknown, and the Bot never started. A model's name now carries a provider only when it begins with the chosen provider's own, as in `anthropic:claude-sonnet-4-5`. +### The Langroid Bot starts on an Anthropic key + +Picked with an Anthropic key, the Langroid Bot exited on startup asking for an OpenAI key. It names +a model from any provider but OpenAI through litellm, which its image did not install, and Langroid +builds an OpenAI client for such a model all the same, from the `OPENAI_API_KEY` Compose writes +empty when the choice was not OpenAI. The image now installs Langroid's litellm extra and an empty +OpenAI key is treated as none, so the Bot starts and answers with the Anthropic model chosen. + ## 0.0.13 ### Fresh desktop setup installs its runtime before sign-in diff --git a/agent-langroid/requirements-test.txt b/agent-langroid/requirements-test.txt new file mode 100644 index 000000000..a91d60c68 --- /dev/null +++ b/agent-langroid/requirements-test.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +pytest==9.0.2 diff --git a/agent-langroid/requirements.txt b/agent-langroid/requirements.txt index 06d9397df..028af3797 100644 --- a/agent-langroid/requirements.txt +++ b/agent-langroid/requirements.txt @@ -1,5 +1,5 @@ ag-ui-langroid -langroid +langroid[litellm] fastapi python-multipart uvicorn[standard] diff --git a/agent-langroid/src/main.py b/agent-langroid/src/main.py index c713932b2..c8468de48 100644 --- a/agent-langroid/src/main.py +++ b/agent-langroid/src/main.py @@ -24,6 +24,13 @@ def _model_id() -> str: return f"litellm/{provider}/{model}" +# An empty `OPENAI_API_KEY` is no key. Compose writes the keys a model choice does not use as empty +# rather than leaving them out, and Langroid reads `OPENAI_API_KEY` as its own setting: given an empty +# one it builds an OpenAI client with it, even for a model it hands to litellm, and the client refuses +# to be built. Unset, Langroid uses its placeholder and litellm reads the chosen provider's own key. +if not os.environ.get("OPENAI_API_KEY"): + os.environ.pop("OPENAI_API_KEY", None) + agent = ChatAgent( ChatAgentConfig( llm=OpenAIGPTConfig(chat_model=_model_id()), diff --git a/agent-langroid/tests/test_main.py b/agent-langroid/tests/test_main.py new file mode 100644 index 000000000..eb89f3867 --- /dev/null +++ b/agent-langroid/tests/test_main.py @@ -0,0 +1,184 @@ +import importlib +import json +import socket +import sys +import threading +import time +from pathlib import Path + +import pytest +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +TOKEN = "test-token" +RUN = { + "threadId": "thread-1", + "runId": "run-1", + "state": {}, + "messages": [{"id": "m1", "role": "user", "content": "Say hello"}], + "tools": [], + "context": [], + "forwardedProps": {}, +} + + +def _sse(events): + async def stream(): + for event in events: + yield event + + return StreamingResponse(stream(), media_type="text/event-stream") + + +def _provider_app(seen): + app = FastAPI() + + @app.post("/v1/chat/completions") + async def openai_chat(request: Request): + body = await request.json() + seen.append(("openai", body["model"])) + if not body.get("stream"): + return JSONResponse( + { + "id": "c", + "object": "chat.completion", + "created": 0, + "model": body["model"], + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hello"}, + } + ], + } + ) + chunk = { + "id": "c", + "object": "chat.completion.chunk", + "created": 0, + "model": body["model"], + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "hello"}, + "finish_reason": None, + } + ], + } + done = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]} + return _sse( + [f"data: {json.dumps(chunk)}\n\n", f"data: {json.dumps(done)}\n\n", "data: [DONE]\n\n"] + ) + + @app.post("/v1/messages") + async def anthropic_messages(request: Request): + body = await request.json() + seen.append(("anthropic", body["model"])) + message = { + "id": "msg", + "type": "message", + "role": "assistant", + "model": body["model"], + "stop_sequence": None, + } + if not body.get("stream"): + return JSONResponse( + { + **message, + "content": [{"type": "text", "text": "hello"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ) + events = [ + ("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}), + ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}), + ("message_stop", {"type": "message_stop"}), + ] + return _sse([f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events]) + + return app + + +@pytest.fixture +def provider(): + seen = [] + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + server = uvicorn.Server( + uvicorn.Config(_provider_app(seen), host="127.0.0.1", port=port, log_level="error") + ) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and time.monotonic() < deadline: + time.sleep(0.01) + yield f"http://127.0.0.1:{port}", seen + server.should_exit = True + thread.join(timeout=10) + + +CHOICES = { + "an Anthropic key": ( + lambda base: { + "BOT_PROVIDER": "anthropic", + "BOT_MODEL": "claude-sonnet-4-5", + "ANTHROPIC_API_KEY": "test-key", + "ANTHROPIC_BASE_URL": base, + "OPENAI_API_KEY": "", + "OPENAI_BASE_URL": "", + }, + ("anthropic", "claude-sonnet-4-5"), + ), + "an OpenAI-compatible endpoint": ( + lambda base: { + "BOT_PROVIDER": "", + "BOT_MODEL": "local-model", + "OPENAI_API_KEY": "no-key-needed", + "OPENAI_BASE_URL": f"{base}/v1", + "ANTHROPIC_API_KEY": "", + }, + ("openai", "local-model"), + ), + "an OpenAI key": ( + lambda base: { + "BOT_PROVIDER": "", + "BOT_MODEL": "gpt-5.5", + "OPENAI_API_KEY": "test-key", + "OPENAI_BASE_URL": f"{base}/v1", + "ANTHROPIC_API_KEY": "", + }, + ("openai", "gpt-5.5"), + ), +} + + +@pytest.mark.parametrize("choice", list(CHOICES)) +def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, choice): + base, seen = provider + environment, expected = CHOICES[choice] + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + for key, value in environment(base).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 response.status_code == 200 + assert '"RUN_FINISHED"' in response.text + assert '"RUN_ERROR"' not in response.text + assert seen == [expected]