diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c192001ba..965ba5f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,6 +297,13 @@ jobs: . .venv-llamaindex/bin/activate python -m pip install --requirement agent-llamaindex/requirements.txt --requirement agent-llamaindex/requirements-test.txt python -m pytest agent-llamaindex/tests -q + - name: Agno model-choice regression + run: | + set -euo pipefail + python -m venv .venv-agno + . .venv-agno/bin/activate + python -m pip install --requirement agent-agno/requirements.txt --requirement agent-agno/requirements-test.txt + python -m pytest agent-agno/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 73b072624..33e090c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The Agno Bot answers on an OpenAI key + +Picked with an OpenAI key, the Agno Bot failed every run before reaching OpenAI. Agno sends a +temperature and a `top_p` with each request, and LiteLLM refuses both for `gpt-5.5`, the model +Compose passes when the setup screen names none, so the run ended in `UnsupportedParamsError`. A +parameter the model does not take is now dropped instead, the way the LlamaIndex Bot already does +it. The Anthropic key and an OpenAI-compatible endpoint behave as before. + ## 0.0.13 ### Fresh desktop setup installs its runtime before sign-in diff --git a/agent-agno/requirements-test.txt b/agent-agno/requirements-test.txt new file mode 100644 index 000000000..a91d60c68 --- /dev/null +++ b/agent-agno/requirements-test.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +pytest==9.0.2 diff --git a/agent-agno/src/main.py b/agent-agno/src/main.py index c7c23f3c8..e52db4b42 100644 --- a/agent-agno/src/main.py +++ b/agent-agno/src/main.py @@ -29,7 +29,11 @@ def _model_id() -> str: # In memory, because a Bot's history lives in OpenBot's database and not in the harness. Two # places remembering the same conversation is how they come to disagree. db=InMemoryDb(), - model=LiteLLM(id=_model_id()), + # `drop_params`, because Agno sends a temperature and a `top_p` on every request and LiteLLM + # refuses both for a reasoning model, the default `gpt-5.5` among them: every run on an OpenAI + # key failed before it reached OpenAI. A parameter a model does not take is dropped instead, + # for this one client, as the LlamaIndex Bot does. + model=LiteLLM(id=_model_id(), request_params={"drop_params": True}), # No role, goal or backstory invented on somebody's behalf. A Bot answers the question it is # asked, and anybody who wants a persona sets one in OpenBot where the rest of them live. instructions="Answer the question you are asked, briefly and correctly.", diff --git a/agent-agno/tests/test_main.py b/agent-agno/tests/test_main.py new file mode 100644 index 000000000..dcf7e22df --- /dev/null +++ b/agent-agno/tests/test_main.py @@ -0,0 +1,186 @@ +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"), + ), + # The default: Compose passes `gpt-5.5` when the model screen names no model, and Agno sends a + # temperature and a `top_p` on every request, which LiteLLM refuses for that reasoning 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( + "/agui", 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]