Files
ciregenzandClaude Opus 4.6 b9b044a2e3 [eric] 9Router subscription proxy, GitHub Copilot auth, Settings restructure
Subscription access (WIP):
- 9Router integration: auto-detects if running, routes through user's subscriptions
- Fallback routing: no API key → check 9Router → use subscription
- Model ID mapping for 9Router (cc/ prefix for Claude Code models)
- GitHub Copilot OAuth device flow (copilot_auth.py, providers/copilot.py)
- Copilot auth endpoints: start-auth, poll-auth, models, disconnect

Settings restructure:
- 4 tabs: General, Models, Usage, Commands
- Models tab: Subscriptions section (9Router + Copilot) + API Keys section
- Per-provider "CONNECTED" badges
- Subscription token fields removed (Anthropic banned OAuth in third-party apps)
- 9Router auto-detection with "Check Connection" button

Provider routing:
- create_provider() checks subscription tokens → API keys → 9Router fallback
- validate_credentials() allows through if 9Router is running
- 9Router model ID mapping (sonnet → cc/claude-sonnet-4-6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:46:26 -07:00

122 lines
4.7 KiB
Python

"""GitHub Copilot provider — routes through Copilot's OpenAI-compatible API.
Uses the user's GitHub Copilot subscription to access Claude, GPT, and other models.
Extends OpenAICompatProvider since Copilot's API speaks the OpenAI format.
"""
from __future__ import annotations
import logging
import time
from typing import Any, AsyncIterator
from openai import AsyncOpenAI
from backend.apps.agents.providers.base import (
BaseProvider, ProviderMessage, StreamEvent, ToolSchema, ModelResponse,
)
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
logger = logging.getLogger(__name__)
COPILOT_API_BASE = "https://api.githubcopilot.com"
class CopilotProvider(OpenAICompatProvider):
"""Provider that routes through GitHub Copilot's API."""
def __init__(self, copilot_token: str):
# Initialize OpenAI client pointing at Copilot's API
self.client = AsyncOpenAI(
api_key=copilot_token,
base_url=COPILOT_API_BASE,
)
# Store token for header injection
self._copilot_token = copilot_token
def get_model_id(self, short_name: str) -> str:
# Copilot uses same model IDs — pass through
return short_name
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
"""Stream with Copilot-specific headers."""
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
"stream": True,
"extra_headers": {
"copilot-integration-id": "vscode-chat",
},
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
stream = await self.client.chat.completions.create(**kwargs)
# Reuse parent's stream parsing logic
text_started = False
text_index = 0
tool_indices: dict[int, dict] = {}
next_block_index = 0
from uuid import uuid4
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason
if delta.content is not None:
if not text_started:
text_started = True
text_index = next_block_index
next_block_index += 1
yield StreamEvent(type="content_block_start", index=text_index, block_type="text")
yield StreamEvent(type="content_block_delta", index=text_index, delta_type="text_delta", text=delta.content)
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tc_idx = tc_delta.index
if tc_idx not in tool_indices:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
text_started = False
block_idx = next_block_index
next_block_index += 1
tool_indices[tc_idx] = {
"block_index": block_idx,
"id": tc_delta.id or uuid4().hex,
"name": tc_delta.function.name if tc_delta.function else "",
"json_buf": "",
}
yield StreamEvent(
type="content_block_start", index=block_idx, block_type="tool_use",
tool_name=tool_indices[tc_idx]["name"], tool_id=tool_indices[tc_idx]["id"],
)
info = tool_indices[tc_idx]
if tc_delta.function and tc_delta.function.name:
info["name"] = tc_delta.function.name
if tc_delta.function and tc_delta.function.arguments:
info["json_buf"] += tc_delta.function.arguments
yield StreamEvent(
type="content_block_delta", index=info["block_index"],
delta_type="input_json_delta", text=tc_delta.function.arguments,
)
if finish_reason is not None:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
for info in tool_indices.values():
yield StreamEvent(type="content_block_stop", index=info["block_index"])
yield StreamEvent(type="message_stop")