From b9876da2703dadccc7a09748fbfa0d367bfb64bd Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:13:20 +0900 Subject: [PATCH] Answer on an OpenAI key from the Agno Bot, not only on Anthropic or an endpoint Agno's LiteLLM model sends `temperature=0.7` and `top_p=1.0` with every request. LiteLLM refuses both for a reasoning model, and `gpt-5.5` is one: it is the model Compose passes to the harness when the setup screen names none, which is what the OpenAI key choice does. So with an OpenAI key every run of the Agno Bot ended in `UnsupportedParamsError` before a request left the container. The model is now built with `drop_params`, scoped to this one client, so a parameter the model does not take is dropped instead of refused. That is how the LlamaIndex Bot already handles the temperature LlamaIndex sends, for the same reason. A model that takes both, such as the one behind an OpenAI-compatible endpoint, still receives them. The new test follows the LlamaIndex Bot's: a local fake provider, the environment the desktop writes for each of the three choices, and an AG-UI run through `/agui`. Before this change the OpenAI key choice fails and the other two pass. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 ++ CHANGELOG.md | 8 ++ agent-agno/requirements-test.txt | 2 + agent-agno/src/main.py | 6 +- agent-agno/tests/test_main.py | 186 +++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 agent-agno/requirements-test.txt create mode 100644 agent-agno/tests/test_main.py 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]