Skip to content

Commit 4279ff1

Browse files
committed
copilot: support Copilot authentication and service
Co-authored-by: GitHub Copilot (claude-opus-4.6) Fixes: #2
1 parent 1015692 commit 4279ff1

9 files changed

Lines changed: 774 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Run the cloud setup script:
7575

7676
Or configure manually:
7777
- Press `,` in the app → configure API key
78-
- Supports OpenAI, Anthropic, OpenRouter
78+
- Supports OpenAI, Anthropic, OpenRouter, Copilot
7979
- Keys stored in `~/.config/meeting-notes/config.yaml`
8080

8181
**Option B: Local AI (Free, private, but slower)**
@@ -187,7 +187,7 @@ python run.py --dev
187187
### Settings
188188

189189
Press `,` to configure:
190-
- AI provider (OpenAI, Anthropic, OpenRouter, Ollama, none)
190+
- AI provider (OpenAI, Anthropic, OpenRouter, Copilot, Ollama, none)
191191
- API keys
192192
- Whisper model (tiny/base/small/medium/large)
193193
- Recording mode (mic/system/combined)

meeting_notes/ai_summarizer.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,3 +459,139 @@ def summarize(self, transcript: str, user_notes: str = "") -> MeetingSummary:
459459
logger.error(f"All {max_retries} attempts failed for OpenRouter API call")
460460
logger.error(error_msg, exc_info=True)
461461
raise
462+
463+
464+
class CopilotSummarizer(BaseSummarizer):
465+
"""Summarizer using GitHub Copilot API (included with Copilot subscription)."""
466+
467+
MODELS = {
468+
"mini": {
469+
"id": "gpt-4o-mini",
470+
"name": "GPT-4o Mini (Copilot)",
471+
},
472+
"standard": {
473+
"id": "gpt-4o",
474+
"name": "GPT-4o (Copilot)",
475+
},
476+
}
477+
478+
def __init__(self, api_key: Optional[str] = None, model: str = "mini"):
479+
"""
480+
Initialize Copilot summarizer.
481+
482+
Args:
483+
api_key: GitHub OAuth access token (from device flow authentication).
484+
model: Model tier - "mini" (gpt-4o-mini) or "standard" (gpt-4o).
485+
"""
486+
if not api_key:
487+
raise ValueError(
488+
"GitHub Copilot token required. "
489+
"Authenticate via Settings > GitHub Copilot > Authenticate with GitHub."
490+
)
491+
492+
if model not in self.MODELS:
493+
raise ValueError(
494+
f"Invalid model: {model}. Choose from: {list(self.MODELS.keys())}"
495+
)
496+
497+
self.model_config = self.MODELS[model]
498+
self.model = self.model_config["id"]
499+
500+
try:
501+
from .copilot_auth import COPILOT_API_BASE
502+
503+
self._oauth_token = api_key
504+
self.copilot_api_base = COPILOT_API_BASE
505+
except Exception as e:
506+
raise RuntimeError(f"Failed to initialize Copilot auth: {e}")
507+
508+
try:
509+
from openai import OpenAI
510+
511+
self._openai_class = OpenAI
512+
except ImportError:
513+
raise ImportError("openai package not installed. Run: pip install openai")
514+
515+
# Create initial client (token used directly as Bearer)
516+
self._client = None
517+
self._current_token = None
518+
519+
def _get_client(self):
520+
"""Get an OpenAI client using the OAuth token directly as Bearer."""
521+
from meeting_notes import __version__
522+
523+
token = self._oauth_token
524+
525+
# Re-create client if token changed
526+
if token != self._current_token:
527+
self._client = self._openai_class(
528+
api_key=token,
529+
base_url=self.copilot_api_base,
530+
default_headers={
531+
"Openai-Intent": "conversation-edits",
532+
"User-Agent": f"meeting-notes/{__version__}",
533+
},
534+
)
535+
self._current_token = token
536+
537+
return self._client
538+
539+
def summarize(self, transcript: str, user_notes: str = "") -> MeetingSummary:
540+
"""Generate summary using GitHub Copilot with retry logic."""
541+
logger.info(f"Generating AI summary with {self.model_config['name']}...")
542+
logger.info(f"Transcript: {len(transcript.split())} words")
543+
print(f"Generating AI summary with {self.model_config['name']}...")
544+
print(f"Transcript: {len(transcript.split())} words")
545+
546+
max_retries = 2
547+
retry_delay = 2 # seconds
548+
549+
for attempt in range(max_retries):
550+
try:
551+
client = self._get_client()
552+
553+
response = client.chat.completions.create(
554+
model=self.model,
555+
messages=[
556+
{
557+
"role": "user",
558+
"content": self._build_prompt(
559+
transcript, user_notes=user_notes
560+
),
561+
}
562+
],
563+
temperature=0.3,
564+
)
565+
566+
# Log token usage if available (no cost tracking - included in subscription)
567+
if response.usage:
568+
total_tokens = (
569+
response.usage.prompt_tokens + response.usage.completion_tokens
570+
)
571+
logger.info(
572+
f"Summary generated ({total_tokens} tokens, included in Copilot subscription)"
573+
)
574+
print(
575+
f"Summary generated ({total_tokens} tokens, included in Copilot subscription)"
576+
)
577+
else:
578+
logger.info("Summary generated (Copilot)")
579+
print("Summary generated (Copilot)")
580+
581+
return self._parse_response(response.choices[0].message.content)
582+
583+
except Exception as e:
584+
error_msg = f"Attempt {attempt + 1}/{max_retries} failed: {type(e).__name__}: {e}"
585+
586+
if attempt < max_retries - 1:
587+
# If it's a token error, force a refresh before retrying
588+
self._current_token = None
589+
logger.warning(error_msg + f" - Retrying in {retry_delay}s...")
590+
print(f"Warning: {error_msg} - Retrying in {retry_delay}s...")
591+
time.sleep(retry_delay)
592+
else:
593+
logger.error(
594+
f"All {max_retries} attempts failed for Copilot API call"
595+
)
596+
logger.error(error_msg, exc_info=True)
597+
raise

meeting_notes/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,8 @@ def __init__(self, dev_mode: bool = False):
676676
api_key = self.config.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
677677
elif self.config.ai_provider == "openrouter":
678678
api_key = self.config.openrouter_api_key or os.getenv("OPENROUTER_API_KEY")
679+
elif self.config.ai_provider == "copilot":
680+
api_key = self.config.github_copilot_token or os.getenv("GITHUB_COPILOT_TOKEN")
679681

680682
self.note_maker = NoteMaker(
681683
output_dir=self.config.notes_dir,
@@ -1517,6 +1519,8 @@ def handle_settings_closed(self, new_config: Optional[AppConfig]) -> None:
15171519
api_key = self.config.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
15181520
elif self.config.ai_provider == "openrouter":
15191521
api_key = self.config.openrouter_api_key or os.getenv("OPENROUTER_API_KEY")
1522+
elif self.config.ai_provider == "copilot":
1523+
api_key = self.config.github_copilot_token or os.getenv("GITHUB_COPILOT_TOKEN")
15201524

15211525
self.note_maker = NoteMaker(
15221526
output_dir=self.config.notes_dir,

meeting_notes/config.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
class AppConfig:
1616
"""Application configuration."""
1717
# AI Summarization
18-
ai_provider: str = "anthropic" # "openai", "anthropic", "openrouter", "local", or "none"
18+
ai_provider: str = "anthropic" # "openai", "anthropic", "openrouter", "copilot", "local", or "none"
1919
ai_model: str = "haiku" # Model tier (varies by provider)
2020

2121
# API Keys (or set environment variables)
2222
openai_api_key: str = "" # OPENAI_API_KEY
2323
anthropic_api_key: str = "" # ANTHROPIC_API_KEY
2424
openrouter_api_key: str = "" # OPENROUTER_API_KEY
25+
github_copilot_token: str = "" # OAuth token from GitHub Device Flow
26+
github_copilot_client_id: str = "" # GITHUB_COPILOT_CLIENT_ID
2527

2628
# Legacy (kept for backwards compatibility)
2729
ollama_model: str = "llama3.2:3b"
@@ -54,6 +56,8 @@ def to_safe_dict(self) -> Dict[str, Any]:
5456
data['anthropic_api_key'] = self._redact_key(data['anthropic_api_key'])
5557
if data.get('openrouter_api_key'):
5658
data['openrouter_api_key'] = self._redact_key(data['openrouter_api_key'])
59+
if data.get('github_copilot_token'):
60+
data['github_copilot_token'] = self._redact_key(data['github_copilot_token'])
5761
return data
5862

5963
@classmethod
@@ -138,7 +142,7 @@ def validate_config(config: AppConfig) -> tuple[bool, Optional[str]]:
138142
(is_valid, error_message)
139143
"""
140144
# Validate AI provider
141-
valid_providers = ["openai", "anthropic", "openrouter", "local", "none"]
145+
valid_providers = ["openai", "anthropic", "openrouter", "copilot", "local", "none"]
142146
if config.ai_provider not in valid_providers:
143147
return False, f"Invalid ai_provider: {config.ai_provider}. Must be one of {valid_providers}"
144148

@@ -177,6 +181,19 @@ def validate_config(config: AppConfig) -> tuple[bool, Optional[str]]:
177181
valid_models = ["cheap", "balanced", "premium"]
178182
if config.ai_model not in valid_models:
179183
return False, f"Invalid ai_model for OpenRouter: {config.ai_model}. Must be one of {valid_models}"
184+
elif config.ai_provider == "copilot":
185+
token = config.github_copilot_token or os.getenv("GITHUB_COPILOT_TOKEN")
186+
if not token:
187+
return False, (
188+
"ai_provider is 'copilot' but not authenticated.\n"
189+
"Set GITHUB_COPILOT_TOKEN environment variable or authenticate in Settings"
190+
)
191+
valid_models = ["mini", "standard"]
192+
if config.ai_model not in valid_models:
193+
return (
194+
False,
195+
f"Invalid ai_model for Copilot: {config.ai_model}. Must be one of {valid_models}",
196+
)
180197

181198
# Validate whisper model
182199
valid_whisper = ["tiny", "base", "small", "medium", "large"]

0 commit comments

Comments
 (0)