From 7f07a231d12837e5653b590594ed92eacf4b24c3 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:42:44 +0900 Subject: [PATCH 1/2] 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 --- .github/workflows/ci.yml | 7 + CHANGELOG.md | 8 ++ agent-microsoft/requirements-test.txt | 2 + agent-microsoft/requirements.txt | 1 + agent-microsoft/src/main.py | 21 ++- agent-microsoft/tests/test_main.py | 180 ++++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 agent-microsoft/requirements-test.txt create mode 100644 agent-microsoft/tests/test_main.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c75f283..c3fe3fbb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3872dd12e..dbc41fd3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/agent-microsoft/requirements-test.txt b/agent-microsoft/requirements-test.txt new file mode 100644 index 000000000..a91d60c68 --- /dev/null +++ b/agent-microsoft/requirements-test.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +pytest==9.0.2 diff --git a/agent-microsoft/requirements.txt b/agent-microsoft/requirements.txt index d96241559..c5f73d486 100644 --- a/agent-microsoft/requirements.txt +++ b/agent-microsoft/requirements.txt @@ -1,4 +1,5 @@ agent-framework-ag-ui +agent-framework-anthropic agent-framework-openai fastapi python-multipart diff --git a/agent-microsoft/src/main.py b/agent-microsoft/src/main.py index 471bda940..1469f69c4 100644 --- a/agent-microsoft/src/main.py +++ b/agent-microsoft/src/main.py @@ -2,6 +2,7 @@ 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 @@ -9,9 +10,23 @@ 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 and base URL 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": + return AnthropicClient(model=model) + return OpenAIChatClient(model) + + +agent = _client().as_agent( + instructions="Answer the question you are asked, briefly and correctly." +) app = FastAPI() diff --git a/agent-microsoft/tests/test_main.py b/agent-microsoft/tests/test_main.py new file mode 100644 index 000000000..f752c3b02 --- /dev/null +++ b/agent-microsoft/tests/test_main.py @@ -0,0 +1,180 @@ +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 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] From fa7175afd774bae35ec5a8f0a14a461ee0ec9d9c Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 18 Sep 2026 18:01:09 -0700 Subject: [PATCH 2/2] Default Microsoft Anthropic requests when the endpoint override is blank --- agent-microsoft/src/main.py | 6 +++-- agent-microsoft/tests/test_main.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/agent-microsoft/src/main.py b/agent-microsoft/src/main.py index 1469f69c4..82af5a934 100644 --- a/agent-microsoft/src/main.py +++ b/agent-microsoft/src/main.py @@ -15,12 +15,14 @@ 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 and base URL from the environment. + 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": - return AnthropicClient(model=model) + # 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) diff --git a/agent-microsoft/tests/test_main.py b/agent-microsoft/tests/test_main.py index f752c3b02..e439493fa 100644 --- a/agent-microsoft/tests/test_main.py +++ b/agent-microsoft/tests/test_main.py @@ -6,6 +6,7 @@ import time from pathlib import Path +import httpx import pytest import uvicorn from fastapi import FastAPI, Request @@ -178,3 +179,41 @@ def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, c 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