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 @@ -325,6 +325,13 @@ jobs:
. .venv-ag2/bin/activate
python -m pip install --requirement agent-ag2/requirements.txt --requirement agent-ag2/requirements-test.txt
python -m pytest agent-ag2/tests -q
- name: Microsoft Agent Framework model-choice regression
run: |
set -euo pipefail
python -m venv .venv-microsoft
. .venv-microsoft/bin/activate
python -m pip install --requirement agent-microsoft/requirements.txt --requirement agent-microsoft/requirements-test.txt
python -m pytest agent-microsoft/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 @@ -40,6 +40,14 @@ Compose writes that one empty when the choice was Anthropic. It now reaches Anth
own Anthropic client when that is the provider chosen, and OpenAI or an OpenAI-compatible endpoint
as before otherwise.

### The Microsoft Agent Framework Bot starts on an Anthropic key

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

## 0.0.13

### Fresh desktop setup installs its runtime before sign-in
Expand Down
2 changes: 2 additions & 0 deletions agent-microsoft/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
1 change: 1 addition & 0 deletions agent-microsoft/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
agent-framework-ag-ui
agent-framework-anthropic
agent-framework-openai
fastapi
python-multipart
Expand Down
23 changes: 20 additions & 3 deletions agent-microsoft/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,33 @@

import os

from agent_framework.anthropic import AnthropicClient
from agent_framework.openai import OpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

TOKEN_HEADER = "x-openbot-agent-token"

agent = OpenAIChatClient(
(os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
).as_agent(instructions="Answer the question you are asked, briefly and correctly.")

def _client() -> AnthropicClient | OpenAIChatClient:
"""The provider the model screen chose, through Agent Framework's own client for it.

`BOT_PROVIDER` is `anthropic` for an Anthropic key and `openai` otherwise, an OpenAI-compatible
endpoint included. Each client reads its own key from the environment.
"""
provider = (os.environ.get("BOT_PROVIDER") or "openai").strip()
model = (os.environ.get("BOT_MODEL") or "gpt-4o-mini").strip()
if provider == "anthropic":
# Compose exports missing overrides as ""; the SDK only defaults an absent URL.
base_url = (os.environ.get("ANTHROPIC_BASE_URL") or "").strip() or "https://api.anthropic.com"
return AnthropicClient(model=model, base_url=base_url)
return OpenAIChatClient(model)


agent = _client().as_agent(
instructions="Answer the question you are asked, briefly and correctly."
)

app = FastAPI()

Expand Down
219 changes: 219 additions & 0 deletions agent-microsoft/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import importlib
import json
import socket
import sys
import threading
import time
from pathlib import Path

import httpx
import pytest
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import 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 name, data in events:
yield f"event: {name}\ndata: {json.dumps(data)}\n\n"

return StreamingResponse(stream(), media_type="text/event-stream")


def _provider_app(seen):
app = FastAPI()

# `OpenAIChatClient` is the Responses API in Agent Framework, so that is the route an OpenAI key
# and an OpenAI-compatible endpoint both reach.
@app.post("/v1/responses")
async def openai_responses(request: Request):
body = await request.json()
seen.append(("openai", body["model"]))
response = {
"id": "resp",
"object": "response",
"created_at": 0,
"model": body["model"],
"status": "in_progress",
"output": [],
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
}
item = {"id": "msg", "type": "message", "role": "assistant", "status": "completed"}
text = {"type": "output_text", "text": "hello", "annotations": []}
done = {
**response,
"status": "completed",
"output": [{**item, "content": [text]}],
"usage": {
"input_tokens": 1,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens": 1,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 2,
},
}
return _sse(
[
("response.created", {"type": "response.created", "sequence_number": 0, "response": response}),
("response.output_item.added", {"type": "response.output_item.added", "sequence_number": 1, "output_index": 0, "item": {**item, "status": "in_progress", "content": []}}),
("response.output_text.delta", {"type": "response.output_text.delta", "sequence_number": 2, "item_id": "msg", "output_index": 0, "content_index": 0, "delta": "hello", "logprobs": []}),
("response.output_item.done", {"type": "response.output_item.done", "sequence_number": 3, "output_index": 0, "item": {**item, "content": [text]}}),
("response.completed", {"type": "response.completed", "sequence_number": 4, "response": done}),
]
)

@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,
}
return _sse(
[
("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 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 "hello" in response.text
assert seen == [expected]


def test_an_anthropic_key_uses_the_official_endpoint_when_compose_sets_a_blank_url(monkeypatch):
seen = []
provider_seen = []
provider_app = _provider_app(provider_seen)

async def respond(transport, request):
seen.append(
(request.url.scheme, request.url.host, request.url.path, request.headers.get("x-api-key"))
)
async with httpx.ASGITransport(app=provider_app) as local_provider:
return await local_provider.handle_async_request(request)

# Keep the real framework and Anthropic clients; replace only the network transport.
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", respond)
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
monkeypatch.setenv("BOT_PROVIDER", "anthropic")
monkeypatch.setenv("BOT_MODEL", "claude-sonnet-4-5")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
monkeypatch.setenv("OPENAI_BASE_URL", "")
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)

from src import main

main = importlib.reload(main)
response = TestClient(main.app).post(
"/", json=RUN, headers={"x-openbot-agent-token": TOKEN}
)

assert seen == [("https", "api.anthropic.com", "/v1/messages", "test-key")]
assert provider_seen == [("anthropic", "claude-sonnet-4-5")]
assert response.status_code == 200
assert '"RUN_FINISHED"' in response.text
assert '"RUN_ERROR"' not in response.text
assert "hello" in response.text
Loading