Skip to content

Commit 28acd25

Browse files
kevin9327claudedavidmckayv
authored
Answer on an Anthropic key from the AG2 Bot, not only on an OpenAI one (#612)
* Answer on an Anthropic key from the AG2 Bot, not only on an OpenAI one The AG2 Bot built `OpenAIConfig(model=BOT_MODEL)` whatever the setup screen chose and never read `BOT_PROVIDER`. The catalogue offers every harness it installs with any model the screen offers, so AG2 can be picked with an Anthropic key: the desktop then writes `BOT_PROVIDER=anthropic`, `BOT_MODEL=claude-sonnet-4-5` and an empty `OPENAI_API_KEY`, and every run failed inside the OpenAI client with `OpenAIError: Missing credentials` before any request was made. AG2 reaches Anthropic through its own `AnthropicConfig`, from the `anthropic` extra, which reads `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` the way the Anthropic SDK does. The harness now picks that config when the provider is `anthropic` and `OpenAIConfig` otherwise, so an OpenAI key and an OpenAI-compatible endpoint behave as they did. AG2 has no LiteLLM config to route through, which is why this is a branch rather than one `provider/model` string as in the LlamaIndex Bot. The test follows the LlamaIndex Bot's. Before this change the Anthropic key choice fails and the other two pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Default AG2 Anthropic requests when the endpoint override is blank --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: David McKay <david@copilotkit.ai>
1 parent 9bd594c commit 28acd25

6 files changed

Lines changed: 267 additions & 3 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,13 @@ jobs:
318318
. .venv-langroid/bin/activate
319319
python -m pip install --requirement agent-langroid/requirements.txt --requirement agent-langroid/requirements-test.txt
320320
python -m pytest agent-langroid/tests -q
321+
- name: AG2 model-choice regression
322+
run: |
323+
set -euo pipefail
324+
python -m venv .venv-ag2
325+
. .venv-ag2/bin/activate
326+
python -m pip install --requirement agent-ag2/requirements.txt --requirement agent-ag2/requirements-test.txt
327+
python -m pytest agent-ag2/tests -q
321328
- run: bun install --frozen-lockfile
322329
- run: bun test tests/compose.test.ts
323330
- run: docker compose --env-file /dev/null --profile harness config --format json >/dev/null

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ builds an OpenAI client for such a model all the same, from the `OPENAI_API_KEY`
3232
empty when the choice was not OpenAI. The image now installs Langroid's litellm extra and an empty
3333
OpenAI key is treated as none, so the Bot starts and answers with the Anthropic model chosen.
3434

35+
### The AG2 Bot answers on an Anthropic key
36+
37+
The AG2 Bot built an OpenAI client whatever the setup screen chose, and read `BOT_MODEL` but never
38+
`BOT_PROVIDER`. Picked with an Anthropic key, every run failed asking for an OpenAI key, because
39+
Compose writes that one empty when the choice was Anthropic. It now reaches Anthropic through AG2's
40+
own Anthropic client when that is the provider chosen, and OpenAI or an OpenAI-compatible endpoint
41+
as before otherwise.
42+
3543
## 0.0.13
3644

3745
### Fresh desktop setup installs its runtime before sign-in

‎agent-ag2/requirements-test.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
httpx==0.28.1
2+
pytest==9.0.2

‎agent-ag2/requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ag2[ag-ui,openai]
1+
ag2[ag-ui,anthropic,openai]
22
fastapi
33
python-multipart
44
uvicorn[standard]

‎agent-ag2/src/main.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,32 @@
44

55
from ag2 import Agent
66
from ag2.ag_ui import AGUIStream
7-
from ag2.config import OpenAIConfig
7+
from ag2.config import AnthropicConfig, OpenAIConfig
88
from fastapi import FastAPI, Request
99
from fastapi.responses import JSONResponse
1010

1111
TOKEN_HEADER = "x-openbot-agent-token"
1212

13+
14+
def _config() -> AnthropicConfig | OpenAIConfig:
15+
"""The provider the model screen chose, which AG2 reaches through a config of its own.
16+
17+
`BOT_PROVIDER` is `anthropic` for an Anthropic key and `openai` otherwise, an OpenAI-compatible
18+
endpoint included. Each SDK reads its own key from the environment.
19+
"""
20+
provider = (os.environ.get("BOT_PROVIDER") or "openai").strip()
21+
model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
22+
if provider == "anthropic":
23+
# Compose exports missing overrides as ""; the SDK only defaults an absent URL.
24+
base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip() or "https://api.anthropic.com"
25+
return AnthropicConfig(model=model, base_url=base_url)
26+
return OpenAIConfig(model=model)
27+
28+
1329
agent = Agent(
1430
name="openbot",
1531
prompt="Answer the question you are asked, briefly and correctly.",
16-
config=OpenAIConfig(model=(os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()),
32+
config=_config(),
1733
)
1834
stream = AGUIStream(agent)
1935

‎agent-ag2/tests/test_main.py‎

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import importlib
2+
import json
3+
import socket
4+
import sys
5+
import threading
6+
import time
7+
from pathlib import Path
8+
9+
import httpx2
10+
import pytest
11+
import uvicorn
12+
from fastapi import FastAPI, Request
13+
from fastapi.responses import JSONResponse, StreamingResponse
14+
from fastapi.testclient import TestClient
15+
16+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17+
18+
TOKEN = "test-token"
19+
RUN = {
20+
"threadId": "thread-1",
21+
"runId": "run-1",
22+
"state": {},
23+
"messages": [{"id": "m1", "role": "user", "content": "Say hello"}],
24+
"tools": [],
25+
"context": [],
26+
"forwardedProps": {},
27+
}
28+
29+
30+
def _sse(events):
31+
async def stream():
32+
for event in events:
33+
yield event
34+
35+
return StreamingResponse(stream(), media_type="text/event-stream")
36+
37+
38+
def _provider_app(seen):
39+
app = FastAPI()
40+
41+
@app.post("/v1/chat/completions")
42+
async def openai_chat(request: Request):
43+
body = await request.json()
44+
seen.append(("openai", body["model"]))
45+
if not body.get("stream"):
46+
return JSONResponse(
47+
{
48+
"id": "c",
49+
"object": "chat.completion",
50+
"created": 0,
51+
"model": body["model"],
52+
"choices": [
53+
{
54+
"index": 0,
55+
"finish_reason": "stop",
56+
"message": {"role": "assistant", "content": "hello"},
57+
}
58+
],
59+
}
60+
)
61+
chunk = {
62+
"id": "c",
63+
"object": "chat.completion.chunk",
64+
"created": 0,
65+
"model": body["model"],
66+
"choices": [
67+
{
68+
"index": 0,
69+
"delta": {"role": "assistant", "content": "hello"},
70+
"finish_reason": None,
71+
}
72+
],
73+
}
74+
done = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
75+
return _sse(
76+
[f"data: {json.dumps(chunk)}\n\n", f"data: {json.dumps(done)}\n\n", "data: [DONE]\n\n"]
77+
)
78+
79+
@app.post("/v1/messages")
80+
async def anthropic_messages(request: Request):
81+
body = await request.json()
82+
seen.append(("anthropic", body["model"]))
83+
message = {
84+
"id": "msg",
85+
"type": "message",
86+
"role": "assistant",
87+
"model": body["model"],
88+
"stop_sequence": None,
89+
}
90+
if not body.get("stream"):
91+
return JSONResponse(
92+
{
93+
**message,
94+
"content": [{"type": "text", "text": "hello"}],
95+
"stop_reason": "end_turn",
96+
"usage": {"input_tokens": 1, "output_tokens": 1},
97+
}
98+
)
99+
events = [
100+
("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
101+
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
102+
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}),
103+
("content_block_stop", {"type": "content_block_stop", "index": 0}),
104+
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}),
105+
("message_stop", {"type": "message_stop"}),
106+
]
107+
return _sse([f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events])
108+
109+
return app
110+
111+
112+
@pytest.fixture
113+
def provider():
114+
seen = []
115+
with socket.socket() as probe:
116+
probe.bind(("127.0.0.1", 0))
117+
port = probe.getsockname()[1]
118+
server = uvicorn.Server(
119+
uvicorn.Config(_provider_app(seen), host="127.0.0.1", port=port, log_level="error")
120+
)
121+
thread = threading.Thread(target=server.run, daemon=True)
122+
thread.start()
123+
deadline = time.monotonic() + 10
124+
while not server.started and time.monotonic() < deadline:
125+
time.sleep(0.01)
126+
yield f"http://127.0.0.1:{port}", seen
127+
server.should_exit = True
128+
thread.join(timeout=10)
129+
130+
131+
CHOICES = {
132+
"an Anthropic key": (
133+
lambda base: {
134+
"BOT_PROVIDER": "anthropic",
135+
"BOT_MODEL": "claude-sonnet-4-5",
136+
"ANTHROPIC_API_KEY": "test-key",
137+
"ANTHROPIC_BASE_URL": base,
138+
"OPENAI_API_KEY": "",
139+
"OPENAI_BASE_URL": "",
140+
},
141+
("anthropic", "claude-sonnet-4-5"),
142+
),
143+
"an OpenAI-compatible endpoint": (
144+
lambda base: {
145+
"BOT_PROVIDER": "",
146+
"BOT_MODEL": "local-model",
147+
"OPENAI_API_KEY": "no-key-needed",
148+
"OPENAI_BASE_URL": f"{base}/v1",
149+
"ANTHROPIC_API_KEY": "",
150+
},
151+
("openai", "local-model"),
152+
),
153+
"an OpenAI key": (
154+
lambda base: {
155+
"BOT_PROVIDER": "",
156+
"BOT_MODEL": "gpt-5.5",
157+
"OPENAI_API_KEY": "test-key",
158+
"OPENAI_BASE_URL": f"{base}/v1",
159+
"ANTHROPIC_API_KEY": "",
160+
},
161+
("openai", "gpt-5.5"),
162+
),
163+
}
164+
165+
166+
@pytest.mark.parametrize("choice", list(CHOICES))
167+
def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, choice):
168+
base, seen = provider
169+
environment, expected = CHOICES[choice]
170+
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
171+
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
172+
for key, value in environment(base).items():
173+
monkeypatch.setenv(key, value)
174+
175+
from src import main
176+
177+
main = importlib.reload(main)
178+
response = TestClient(main.app).post(
179+
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
180+
)
181+
182+
assert response.status_code == 200
183+
assert '"RUN_FINISHED"' in response.text
184+
assert '"RUN_ERROR"' not in response.text
185+
assert seen == [expected]
186+
187+
188+
def test_an_anthropic_key_uses_the_official_endpoint_when_compose_sets_a_blank_url(monkeypatch):
189+
seen = []
190+
191+
async def respond(transport, request):
192+
body = json.loads(request.content)
193+
seen.append((str(request.url), request.headers.get("x-api-key"), body["model"]))
194+
return httpx2.Response(
195+
200,
196+
request=request,
197+
json={
198+
"id": "msg",
199+
"type": "message",
200+
"role": "assistant",
201+
"model": body["model"],
202+
"content": [{"type": "text", "text": "hello"}],
203+
"stop_reason": "end_turn",
204+
"stop_sequence": None,
205+
"usage": {"input_tokens": 1, "output_tokens": 1},
206+
},
207+
)
208+
209+
# Keep the real AG2 and Anthropic clients; replace only the network transport.
210+
monkeypatch.setattr(httpx2.AsyncHTTPTransport, "handle_async_request", respond)
211+
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
212+
monkeypatch.setenv("BOT_PROVIDER", "anthropic")
213+
monkeypatch.setenv("BOT_MODEL", "claude-sonnet-4-5")
214+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
215+
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
216+
monkeypatch.setenv("OPENAI_API_KEY", "")
217+
monkeypatch.setenv("OPENAI_BASE_URL", "")
218+
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
219+
220+
from src import main
221+
222+
main = importlib.reload(main)
223+
response = TestClient(main.app).post(
224+
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
225+
)
226+
227+
assert seen == [("https://api.anthropic.com/v1/messages", "test-key", "claude-sonnet-4-5")]
228+
assert response.status_code == 200
229+
assert '"RUN_FINISHED"' in response.text
230+
assert '"RUN_ERROR"' not in response.text
231+
assert "hello" in response.text

0 commit comments

Comments
 (0)