Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions agent-langroid/requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
httpx==0.28.1
pytest==9.0.2
2 changes: 1 addition & 1 deletion agent-langroid/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ag-ui-langroid
langroid
langroid[litellm]
fastapi
python-multipart
uvicorn[standard]
7 changes: 7 additions & 0 deletions agent-langroid/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
184 changes: 184 additions & 0 deletions agent-langroid/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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]
Loading