Skip to content

Commit 2f1508a

Browse files
committed
feat: Implement GitHub Copilot authentication and integrate with LangChain
- Added CopilotAuthenticator for OAuth device-flow authentication with GitHub Copilot API. - Created functions to manage access tokens and API keys, including caching mechanisms. - Developed Copilot LLM factory to build LangChain clients for GitHub Copilot. - Introduced SSE connection management with a singleton pattern for real-time event handling. - Refactored activity and run management to utilize new SSE connection for improved event handling. - Updated frontend components to handle connection states and errors more effectively. - Cleaned up setup script by removing unnecessary instructions related to LLM backend configuration. Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent 0ee8554 commit 2f1508a

24 files changed

Lines changed: 880 additions & 1284 deletions

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
"Bash(npx playwright test --project=chromium --reporter=line)",
1717
"Bash(npx playwright test:*)",
1818
"Bash(find:*)",
19-
"Bash(python:*)"
19+
"Bash(python:*)",
20+
"WebFetch(domain:github.com)",
21+
"WebFetch(domain:pypi.org)",
22+
"WebFetch(domain:deepwiki.com)",
23+
"Bash(pip list:*)"
2024
],
2125
"deny": [],
2226
"ask": []

.env.example

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
# LiteLLM Configuration (Default LLM backend)
2-
# Works out of the box with GitHub Copilot — no API keys needed.
3-
# Supports 100+ providers: GitHub Copilot, Ollama, Anthropic, etc.
4-
# Docs: https://docs.litellm.ai/docs/providers
1+
# GitHub Copilot Configuration (Default LLM backend)
2+
# Authentication: on first run, you'll be prompted to authenticate via device flow.
3+
# Tokens are cached at ~/.config/copilot-llm/ and auto-refresh.
4+
# Note: Regular GitHub PATs do NOT work with the Copilot Chat API.
55

6-
# Primary model (default: github_copilot/gpt-4o)
7-
# LITELLM_MODEL=github_copilot/gpt-4o
6+
# Primary model (default: gpt-4o)
7+
# COPILOT_MODEL=gpt-4o
88

99
# Fallback chain (comma-separated, tried in order if primary fails)
10-
# LITELLM_FALLBACK_MODELS=github_copilot/claude-sonnet-4,github_copilot/gpt-4o,github_copilot/gpt-4o-mini
10+
# COPILOT_FALLBACK_MODELS=claude-sonnet-4,gpt-4o,gpt-4o-mini
1111

12-
# OpenAI Configuration (Optional — overrides LiteLLM when set)
12+
# OpenAI Configuration (Optional — overrides Copilot when AGENT_BACKEND=openai)
1313
# Get your API key from: https://platform.openai.com/api-keys
1414
# If set, uses OpenAI SDK directly with beta.chat.completions.parse()
1515

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ The `@operation` decorator auto-generates: REST endpoint, MCP JSON-RPC method, J
7676
- `agent_builder/` — Full agent lifecycle: `service.py` (WorkbenchService), `engine/` (ReAct loop), `persistence/` (SQLite repos), `tools/` (ToolRegistry, MCP adapters), `routes.py` (Blueprint at `/api/workbench/*`)
7777
- `kba_service.py` — Knowledge Base Article generation pipeline
7878

79-
**LLM config**: LiteLLM is default (works with GitHub Copilot, no key needed); OpenAI is optional override. Config via `.env`.
79+
**LLM config**: GitHub Copilot is default (uses device flow or GITHUB_TOKEN); OpenAI is optional override. Config via `.env`.
8080

8181
**MCP**: Backend exposes both REST and MCP JSON-RPC from the same `@operation` handlers. `mcp_handler.py` routes JSON-RPC to operations.
8282

@@ -105,8 +105,8 @@ Backend tests use pytest. `agent_builder/tests/` has 132 tests covering the agen
105105

106106
Copy `.env.example` to `.env`. Key variables:
107107
```
108-
# LiteLLM (default — no key required if using GitHub Copilot)
109-
LITELLM_MODEL=github_copilot/gpt-4o
108+
# GitHub Copilot (default — uses GITHUB_TOKEN or device flow)
109+
COPILOT_MODEL=gpt-4o
110110
111111
# OpenAI (optional override)
112112
OPENAI_API_KEY=sk-proj-...

backend/agent_builder/chat_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _default_model(explicit_model: str = "") -> str:
3737
return explicit_model
3838
if os.getenv("AGENT_BACKEND", "").strip().lower() == "openai":
3939
return os.getenv("OPENAI_MODEL", "gpt-4o-mini")
40-
return os.getenv("LITELLM_MODEL", "github_copilot/gpt-4o")
40+
return os.getenv("COPILOT_MODEL", "gpt-4o")
4141

4242

4343
class ChatService:

backend/agent_builder/engine/event_bus.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,24 @@ def to_sse_dict(self) -> dict[str, Any]:
3939

4040

4141
class AgentEventBus:
42-
"""Simple in-process pub/sub for agent execution events."""
42+
"""Simple in-process pub/sub for agent execution events.
43+
44+
Thread-safe: ``publish()`` can be called from any thread (e.g. LangChain
45+
callback handlers running on executor threads). It uses
46+
``loop.call_soon_threadsafe`` to schedule the actual ``put_nowait`` on
47+
the event-loop thread, which is required because ``asyncio.Queue`` is
48+
**not** thread-safe.
49+
"""
4350

4451
def __init__(self, max_history: int = MAX_HISTORY) -> None:
4552
self._subscribers: list[asyncio.Queue[AgentEvent]] = []
4653
self._history: list[AgentEvent] = []
4754
self._max_history = max_history
55+
self._loop: asyncio.AbstractEventLoop | None = None
56+
57+
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
58+
"""Capture the running event loop (call once at startup)."""
59+
self._loop = loop
4860

4961
def subscribe(self) -> asyncio.Queue[AgentEvent]:
5062
q: asyncio.Queue[AgentEvent] = asyncio.Queue(maxsize=500)
@@ -59,21 +71,40 @@ def unsubscribe(self, q: asyncio.Queue[AgentEvent]) -> None:
5971
except ValueError:
6072
pass
6173

74+
def _dispatch(self, event: AgentEvent) -> None:
75+
"""Put event into all subscriber queues. MUST run on the event-loop thread."""
76+
for q in self._subscribers:
77+
try:
78+
q.put_nowait(event)
79+
except asyncio.QueueFull:
80+
logger.warning("EventBus: subscriber queue full, dropping event")
81+
6282
def publish(self, event: AgentEvent) -> None:
6383
"""Publish an event to all subscribers and history buffer.
6484
65-
Synchronoussafe to call from LangChain BaseCallbackHandler methods
66-
because asyncio.Queue.put_nowait() doesn't require awaiting.
85+
Thread-safecan be called from LangChain callback handlers that
86+
run on background executor threads.
6787
"""
6888
self._history.append(event)
6989
if len(self._history) > self._max_history:
7090
self._history = self._history[-self._max_history:]
7191

72-
for q in self._subscribers:
92+
loop = self._loop
93+
if loop is None:
94+
# Fallback: try to get the running loop (works when called from
95+
# the event-loop thread itself).
7396
try:
74-
q.put_nowait(event)
75-
except asyncio.QueueFull:
76-
logger.warning("EventBus: subscriber queue full, dropping event")
97+
loop = asyncio.get_running_loop()
98+
except RuntimeError:
99+
pass
100+
101+
if loop is not None and loop.is_running():
102+
# Schedule queue puts on the event-loop thread so asyncio.Queue
103+
# internals are never touched from a foreign thread.
104+
loop.call_soon_threadsafe(self._dispatch, event)
105+
else:
106+
# No loop available (unlikely in production) — direct dispatch.
107+
self._dispatch(event)
77108

78109
def get_history(self) -> list[AgentEvent]:
79110
return list(self._history)

backend/agent_builder/engine/react_runner.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def build_llm(
4040
max_tokens: int = 0,
4141
reasoning_effort: str = "low",
4242
) -> Any:
43-
"""Construct an LLM instance — LiteLLM by default, OpenAI only when forced."""
43+
"""Construct an LLM instance — Copilot by default, OpenAI only when forced."""
4444
if _force_openai_backend() and api_key:
4545
from langchain_openai import ChatOpenAI
4646
kwargs: dict[str, Any] = {
@@ -55,9 +55,13 @@ def build_llm(
5555
kwargs["reasoning_effort"] = reasoning_effort
5656
return ChatOpenAI(**kwargs)
5757
else:
58-
from langchain_litellm import ChatLiteLLM
59-
litellm_model = model or os.getenv("LITELLM_MODEL", "github_copilot/gpt-4o")
60-
return ChatLiteLLM(model=litellm_model, temperature=temperature)
58+
from copilot_llm import build_copilot_llm
59+
return build_copilot_llm(
60+
model=model or "",
61+
temperature=temperature,
62+
max_tokens=max_tokens,
63+
reasoning_effort=reasoning_effort,
64+
)
6165

6266

6367
def build_react_agent(

backend/agent_builder/routes.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -302,34 +302,48 @@ async def workbench_event_stream():
302302
"""Server-Sent Events endpoint for real-time agent activity.
303303
304304
Supports optional ?run_id=X to filter events for a specific run.
305+
Sends periodic heartbeat comments to keep the connection alive
306+
through proxies and prevent browser timeouts.
305307
"""
308+
from quart import make_response
309+
306310
run_id_filter = request.args.get("run_id")
307311
queue = agent_event_bus.subscribe()
308312

309313
async def generate():
310314
try:
315+
# Send initial SSE comment so the connection is established immediately
316+
yield ": connected\n\n"
317+
311318
# Send history buffer as catch-up
312319
for event in agent_event_bus.get_history():
313320
if run_id_filter and event.run_id != run_id_filter:
314321
continue
315322
yield f"data: {json.dumps(event.to_sse_dict())}\n\n"
316323

317-
# Stream new events
324+
# Stream new events with periodic heartbeat
318325
while True:
319-
event = await queue.get()
320-
if run_id_filter and event.run_id != run_id_filter:
321-
continue
322-
yield f"data: {json.dumps(event.to_sse_dict())}\n\n"
326+
try:
327+
event = await asyncio.wait_for(queue.get(), timeout=15)
328+
if run_id_filter and event.run_id != run_id_filter:
329+
continue
330+
yield f"data: {json.dumps(event.to_sse_dict())}\n\n"
331+
except asyncio.TimeoutError:
332+
# Send SSE comment as keepalive to prevent proxy/browser timeout
333+
yield ": heartbeat\n\n"
323334
except asyncio.CancelledError:
324335
pass
325336
finally:
326337
agent_event_bus.unsubscribe(queue)
327338

328-
return generate(), {
339+
response = await make_response(generate(), {
329340
"Content-Type": "text/event-stream",
330341
"Cache-Control": "no-cache",
342+
"Connection": "keep-alive",
331343
"X-Accel-Buffering": "no",
332-
}
344+
})
345+
response.timeout = None
346+
return response
333347

334348

335349
# ---------------------------------------------------------------------------

backend/agent_builder/service.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _default_model(explicit_model: str = "") -> str:
5555
return explicit_model
5656
if os.getenv("AGENT_BACKEND", "").strip().lower() == "openai":
5757
return os.getenv("OPENAI_MODEL", "gpt-4o-mini")
58-
return os.getenv("LITELLM_MODEL", "github_copilot/gpt-4o")
58+
return os.getenv("COPILOT_MODEL", "gpt-4o")
5959

6060

6161
# ============================================================================
@@ -766,6 +766,7 @@ async def continue_thread(self, thread_id: str, user_message: str):
766766
single async generator interface.
767767
"""
768768
import uuid as uuid_mod
769+
769770
from .engine.ag_ui_events import (
770771
encode_event,
771772
run_error_event,
@@ -775,13 +776,13 @@ async def continue_thread(self, thread_id: str, user_message: str):
775776
step_finished_event,
776777
step_started_event,
777778
structured_output_event,
778-
text_message_start,
779779
text_message_content,
780780
text_message_end,
781-
tool_call_start,
781+
text_message_start,
782782
tool_call_args,
783783
tool_call_end,
784784
tool_call_result,
785+
tool_call_start,
785786
)
786787

787788
thread = self._repo.get_thread(thread_id)

backend/agent_builder/tests/test_engine.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,15 @@ def test_mixed_sources(self):
7575

7676

7777
class TestBuildLlmSelection:
78-
def test_defaults_to_litellm_even_with_api_key(self, monkeypatch):
78+
def test_defaults_to_copilot_even_with_api_key(self, monkeypatch):
7979
monkeypatch.delenv("AGENT_BACKEND", raising=False)
8080

81-
with patch("langchain_litellm.ChatLiteLLM") as mock_litellm:
81+
with patch("copilot_llm.build_copilot_llm") as mock_copilot:
8282
from agent_builder.engine.react_runner import build_llm
8383

84-
build_llm("openai/nvidia/nemotron-3-nano-4b", api_key="test-key")
84+
build_llm("gpt-4o", api_key="test-key")
8585

86-
mock_litellm.assert_called_once()
87-
assert mock_litellm.call_args.kwargs["model"] == "openai/nvidia/nemotron-3-nano-4b"
86+
mock_copilot.assert_called_once()
8887

8988
def test_uses_openai_when_explicitly_forced(self, monkeypatch):
9089
monkeypatch.setenv("AGENT_BACKEND", "openai")

backend/agent_builder/tests/test_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ def _make_service(tmp_path: Path) -> WorkbenchService:
3535

3636

3737
class TestWorkbenchServiceCRUD:
38-
def test_defaults_to_litellm_model_when_not_forced(self, monkeypatch):
38+
def test_defaults_to_copilot_model_when_not_forced(self, monkeypatch):
3939
monkeypatch.delenv("AGENT_BACKEND", raising=False)
40-
monkeypatch.setenv("LITELLM_MODEL", "openai/nvidia/nemotron-3-nano-4b")
40+
monkeypatch.setenv("COPILOT_MODEL", "gpt-4o")
4141
monkeypatch.setenv("OPENAI_MODEL", "gpt-4o-mini")
4242

4343
with TemporaryDirectory() as tmp:
@@ -47,7 +47,7 @@ def test_defaults_to_litellm_model_when_not_forced(self, monkeypatch):
4747
db_path=Path(tmp) / "test.db",
4848
openai_api_key="test-key",
4949
)
50-
assert svc._model == "openai/nvidia/nemotron-3-nano-4b"
50+
assert svc._model == "gpt-4o"
5151

5252
def test_create_and_get_agent(self):
5353
with TemporaryDirectory() as tmp:

0 commit comments

Comments
 (0)