Skip to content

Commit 0ca8982

Browse files
kevin9327claudedavidmckayv
authored
Start the Microsoft Agent Framework Bot on an Anthropic key (#613)
* Start the Microsoft Agent Framework Bot on an Anthropic key The Microsoft Agent Framework Bot built `OpenAIChatClient(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 this one 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`. The OpenAI client refused to be built without a key, so the module failed at import with `OpenAIError: Missing credentials` and the container never served a run. Agent Framework reaches Anthropic through its own `AnthropicClient`, published as `agent-framework-anthropic` and re-exported from `agent_framework.anthropic`, which reads `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL`. The harness now builds that client when the provider is `anthropic` and `OpenAIChatClient` otherwise, so an OpenAI key and an OpenAI-compatible endpoint behave as they did. The test follows the LlamaIndex Bot's, with the Responses API faked because that is the route `OpenAIChatClient` takes. Before this change the Anthropic key choice fails at import and the other two pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Default Microsoft 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 3af815a commit 0ca8982

6 files changed

Lines changed: 257 additions & 3 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,13 @@ jobs:
325325
. .venv-ag2/bin/activate
326326
python -m pip install --requirement agent-ag2/requirements.txt --requirement agent-ag2/requirements-test.txt
327327
python -m pytest agent-ag2/tests -q
328+
- name: Microsoft Agent Framework model-choice regression
329+
run: |
330+
set -euo pipefail
331+
python -m venv .venv-microsoft
332+
. .venv-microsoft/bin/activate
333+
python -m pip install --requirement agent-microsoft/requirements.txt --requirement agent-microsoft/requirements-test.txt
334+
python -m pytest agent-microsoft/tests -q
328335
- run: bun install --frozen-lockfile
329336
- run: bun test tests/compose.test.ts
330337
- 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
@@ -47,6 +47,14 @@ Compose writes that one empty when the choice was Anthropic. It now reaches Anth
4747
own Anthropic client when that is the provider chosen, and OpenAI or an OpenAI-compatible endpoint
4848
as before otherwise.
4949

50+
### The Microsoft Agent Framework Bot starts on an Anthropic key
51+
52+
The Microsoft Agent Framework Bot built an OpenAI client whatever the setup screen chose, and read
53+
`BOT_MODEL` but never `BOT_PROVIDER`. Picked with an Anthropic key, it exited on startup asking for
54+
an OpenAI key, because Compose writes that one empty when the choice was Anthropic. It now reaches
55+
Anthropic through Agent Framework's own Anthropic client when that is the provider chosen, and
56+
OpenAI or an OpenAI-compatible endpoint as before otherwise.
57+
5058
## 0.0.13
5159

5260
### Fresh desktop setup installs its runtime before sign-in
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-microsoft/requirements.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
agent-framework-ag-ui
2+
agent-framework-anthropic
23
agent-framework-openai
34
fastapi
45
python-multipart

‎agent-microsoft/src/main.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,33 @@
22

33
import os
44

5+
from agent_framework.anthropic import AnthropicClient
56
from agent_framework.openai import OpenAIChatClient
67
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
78
from fastapi import FastAPI, Request
89
from fastapi.responses import JSONResponse
910

1011
TOKEN_HEADER = "x-openbot-agent-token"
1112

12-
agent = OpenAIChatClient(
13-
(os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
14-
).as_agent(instructions="Answer the question you are asked, briefly and correctly.")
13+
14+
def _client() -> AnthropicClient | OpenAIChatClient:
15+
"""The provider the model screen chose, through Agent Framework's own client for it.
16+
17+
`BOT_PROVIDER` is `anthropic` for an Anthropic key and `openai` otherwise, an OpenAI-compatible
18+
endpoint included. Each client 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 AnthropicClient(model=model, base_url=base_url)
26+
return OpenAIChatClient(model)
27+
28+
29+
agent = _client().as_agent(
30+
instructions="Answer the question you are asked, briefly and correctly."
31+
)
1532

1633
app = FastAPI()
1734

‎agent-microsoft/tests/test_main.py‎

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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 httpx
10+
import pytest
11+
import uvicorn
12+
from fastapi import FastAPI, Request
13+
from fastapi.responses import 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 name, data in events:
33+
yield f"event: {name}\ndata: {json.dumps(data)}\n\n"
34+
35+
return StreamingResponse(stream(), media_type="text/event-stream")
36+
37+
38+
def _provider_app(seen):
39+
app = FastAPI()
40+
41+
# `OpenAIChatClient` is the Responses API in Agent Framework, so that is the route an OpenAI key
42+
# and an OpenAI-compatible endpoint both reach.
43+
@app.post("/v1/responses")
44+
async def openai_responses(request: Request):
45+
body = await request.json()
46+
seen.append(("openai", body["model"]))
47+
response = {
48+
"id": "resp",
49+
"object": "response",
50+
"created_at": 0,
51+
"model": body["model"],
52+
"status": "in_progress",
53+
"output": [],
54+
"parallel_tool_calls": True,
55+
"tool_choice": "auto",
56+
"tools": [],
57+
}
58+
item = {"id": "msg", "type": "message", "role": "assistant", "status": "completed"}
59+
text = {"type": "output_text", "text": "hello", "annotations": []}
60+
done = {
61+
**response,
62+
"status": "completed",
63+
"output": [{**item, "content": [text]}],
64+
"usage": {
65+
"input_tokens": 1,
66+
"input_tokens_details": {"cached_tokens": 0},
67+
"output_tokens": 1,
68+
"output_tokens_details": {"reasoning_tokens": 0},
69+
"total_tokens": 2,
70+
},
71+
}
72+
return _sse(
73+
[
74+
("response.created", {"type": "response.created", "sequence_number": 0, "response": response}),
75+
("response.output_item.added", {"type": "response.output_item.added", "sequence_number": 1, "output_index": 0, "item": {**item, "status": "in_progress", "content": []}}),
76+
("response.output_text.delta", {"type": "response.output_text.delta", "sequence_number": 2, "item_id": "msg", "output_index": 0, "content_index": 0, "delta": "hello", "logprobs": []}),
77+
("response.output_item.done", {"type": "response.output_item.done", "sequence_number": 3, "output_index": 0, "item": {**item, "content": [text]}}),
78+
("response.completed", {"type": "response.completed", "sequence_number": 4, "response": done}),
79+
]
80+
)
81+
82+
@app.post("/v1/messages")
83+
async def anthropic_messages(request: Request):
84+
body = await request.json()
85+
seen.append(("anthropic", body["model"]))
86+
message = {
87+
"id": "msg",
88+
"type": "message",
89+
"role": "assistant",
90+
"model": body["model"],
91+
"stop_sequence": None,
92+
}
93+
return _sse(
94+
[
95+
("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
96+
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
97+
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}),
98+
("content_block_stop", {"type": "content_block_stop", "index": 0}),
99+
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}),
100+
("message_stop", {"type": "message_stop"}),
101+
]
102+
)
103+
104+
return app
105+
106+
107+
@pytest.fixture
108+
def provider():
109+
seen = []
110+
with socket.socket() as probe:
111+
probe.bind(("127.0.0.1", 0))
112+
port = probe.getsockname()[1]
113+
server = uvicorn.Server(
114+
uvicorn.Config(_provider_app(seen), host="127.0.0.1", port=port, log_level="error")
115+
)
116+
thread = threading.Thread(target=server.run, daemon=True)
117+
thread.start()
118+
deadline = time.monotonic() + 10
119+
while not server.started and time.monotonic() < deadline:
120+
time.sleep(0.01)
121+
yield f"http://127.0.0.1:{port}", seen
122+
server.should_exit = True
123+
thread.join(timeout=10)
124+
125+
126+
CHOICES = {
127+
"an Anthropic key": (
128+
lambda base: {
129+
"BOT_PROVIDER": "anthropic",
130+
"BOT_MODEL": "claude-sonnet-4-5",
131+
"ANTHROPIC_API_KEY": "test-key",
132+
"ANTHROPIC_BASE_URL": base,
133+
"OPENAI_API_KEY": "",
134+
"OPENAI_BASE_URL": "",
135+
},
136+
("anthropic", "claude-sonnet-4-5"),
137+
),
138+
"an OpenAI-compatible endpoint": (
139+
lambda base: {
140+
"BOT_PROVIDER": "",
141+
"BOT_MODEL": "local-model",
142+
"OPENAI_API_KEY": "no-key-needed",
143+
"OPENAI_BASE_URL": f"{base}/v1",
144+
"ANTHROPIC_API_KEY": "",
145+
},
146+
("openai", "local-model"),
147+
),
148+
"an OpenAI key": (
149+
lambda base: {
150+
"BOT_PROVIDER": "",
151+
"BOT_MODEL": "gpt-5.5",
152+
"OPENAI_API_KEY": "test-key",
153+
"OPENAI_BASE_URL": f"{base}/v1",
154+
"ANTHROPIC_API_KEY": "",
155+
},
156+
("openai", "gpt-5.5"),
157+
),
158+
}
159+
160+
161+
@pytest.mark.parametrize("choice", list(CHOICES))
162+
def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, choice):
163+
base, seen = provider
164+
environment, expected = CHOICES[choice]
165+
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
166+
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
167+
for key, value in environment(base).items():
168+
monkeypatch.setenv(key, value)
169+
170+
from src import main
171+
172+
main = importlib.reload(main)
173+
response = TestClient(main.app).post(
174+
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
175+
)
176+
177+
assert response.status_code == 200
178+
assert '"RUN_FINISHED"' in response.text
179+
assert '"RUN_ERROR"' not in response.text
180+
assert "hello" in response.text
181+
assert seen == [expected]
182+
183+
184+
def test_an_anthropic_key_uses_the_official_endpoint_when_compose_sets_a_blank_url(monkeypatch):
185+
seen = []
186+
provider_seen = []
187+
provider_app = _provider_app(provider_seen)
188+
189+
async def respond(transport, request):
190+
seen.append(
191+
(request.url.scheme, request.url.host, request.url.path, request.headers.get("x-api-key"))
192+
)
193+
async with httpx.ASGITransport(app=provider_app) as local_provider:
194+
return await local_provider.handle_async_request(request)
195+
196+
# Keep the real framework and Anthropic clients; replace only the network transport.
197+
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", respond)
198+
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
199+
monkeypatch.setenv("BOT_PROVIDER", "anthropic")
200+
monkeypatch.setenv("BOT_MODEL", "claude-sonnet-4-5")
201+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
202+
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
203+
monkeypatch.setenv("OPENAI_API_KEY", "")
204+
monkeypatch.setenv("OPENAI_BASE_URL", "")
205+
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
206+
207+
from src import main
208+
209+
main = importlib.reload(main)
210+
response = TestClient(main.app).post(
211+
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
212+
)
213+
214+
assert seen == [("https", "api.anthropic.com", "/v1/messages", "test-key")]
215+
assert provider_seen == [("anthropic", "claude-sonnet-4-5")]
216+
assert response.status_code == 200
217+
assert '"RUN_FINISHED"' in response.text
218+
assert '"RUN_ERROR"' not in response.text
219+
assert "hello" in response.text

0 commit comments

Comments
 (0)