From c1a14e884e170ecc0f2778f2eda1ef4dedb6b010 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 13:01:07 +1000 Subject: [PATCH 1/9] fix: rewrite Copilot driver with OAuth device flow authentication The Copilot LLM driver was broken - it expected users to provide a GITHUB_TOKEN env var, but no standard token type (PAT, gh CLI token) works with the Copilot token exchange endpoint. Changes: - Full rewrite of copilot.rs with OAuth device flow using Copilot's client ID (Iv1.b507a08c87ecfe98) - Three-layer token chain: ghu_ (8h) -> Copilot API token (30min), with automatic caching and refresh - Dynamic model fetching from Copilot API on daemon startup and on model_not_supported error - Init wizard: TUI auth screen with device code display, live model picker after authentication - set-key command: interactive device flow for github-copilot provider - Doctor: detects Copilot auth via persisted token file - Removed static Copilot model entries (now fetched dynamically) - Simplified driver instantiation (no env vars needed) Tested end-to-end with Copilot Enterprise: auth, token exchange, 43 models fetched, completions working with claude-opus-4.6-1m. Closes #1014 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-cli/src/main.rs | 33 + .../src/tui/screens/init_wizard.rs | 278 ++++- crates/openfang-kernel/src/kernel.rs | 48 + .../openfang-runtime/src/drivers/copilot.rs | 949 +++++++++++++++--- crates/openfang-runtime/src/drivers/mod.rs | 40 +- crates/openfang-runtime/src/model_catalog.rs | 55 +- 6 files changed, 1175 insertions(+), 228 deletions(-) diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index 64cffd06..553b8c25 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -2457,6 +2457,18 @@ decay_rate = 0.05 } } + // Check GitHub Copilot auth (separate from env var checks) + { + let openfang_dir = cli_openfang_home(); + if openfang_runtime::drivers::copilot::copilot_auth_available(&openfang_dir) { + any_key_set = true; + if !json { + ui::check_ok("GitHub Copilot (authenticated via device flow)"); + } + checks.push(serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"})); + } + } + if !any_key_set { if !json { println!(); @@ -4969,6 +4981,27 @@ fn cmd_config_unset(key: &str) { } fn cmd_config_set_key(provider: &str) { + // GitHub Copilot uses OAuth device flow, not a simple API key paste. + if provider == "github-copilot" || provider == "copilot" { + let openfang_dir = cli_openfang_home(); + let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| { + ui::error(&format!("Failed to create async runtime: {e}")); + std::process::exit(1); + }); + match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(&openfang_dir)) { + Ok(_) => { + ui::success("GitHub Copilot configured successfully"); + ui::hint("Restart the daemon: openfang stop && openfang start"); + } + Err(e) => { + ui::error(&format!("Copilot setup failed: {e}")); + ui::hint("Check your Client ID/Secret and try again"); + std::process::exit(1); + } + } + return; + } + let env_var = provider_to_env_var(provider); let key = prompt_input(&format!(" Paste your {provider} API key: ")); diff --git a/crates/openfang-cli/src/tui/screens/init_wizard.rs b/crates/openfang-cli/src/tui/screens/init_wizard.rs index 279b1e6c..f7cb51eb 100644 --- a/crates/openfang-cli/src/tui/screens/init_wizard.rs +++ b/crates/openfang-cli/src/tui/screens/init_wizard.rs @@ -159,10 +159,10 @@ const PROVIDERS: &[ProviderInfo] = &[ ProviderInfo { name: "github-copilot", display: "GitHub Copilot", - env_var: "GITHUB_TOKEN", - default_model: "gpt-4o", - needs_key: true, - hint: "via PAT", + env_var: "", + default_model: "claude-sonnet-4.6", + needs_key: false, // Auth handled via OAuth device flow after init + hint: "free with subscription", }, ProviderInfo { name: "replicate", @@ -257,6 +257,7 @@ enum Step { Welcome, Migration, Provider, + CopilotAuth, ApiKey, Model, Routing, @@ -288,6 +289,26 @@ enum KeyTestState { Warn, } +#[derive(Clone, PartialEq, Eq)] +enum CopilotAuthStatus { + /// Requesting device code from GitHub. + Starting, + /// Waiting for user to authorize in browser. + WaitingForUser, + /// Authorized, fetching models. + FetchingModels, + /// Done — models loaded. + Done, + /// Error. + Failed(String), +} + +enum CopilotAuthEvent { + DeviceCode { user_code: String, verification_uri: String }, + Authenticated, + Models(Vec), +} + /// A model entry for list display. struct ModelEntry { id: String, @@ -348,6 +369,11 @@ struct State { daemon_url: String, daemon_error: String, saving_done: bool, + + // Copilot auth + copilot_user_code: String, + copilot_verification_uri: String, + copilot_auth_status: CopilotAuthStatus, save_error: String, } @@ -386,6 +412,9 @@ impl State { daemon_error: String::new(), saving_done: false, save_error: String::new(), + copilot_user_code: String::new(), + copilot_verification_uri: String::new(), + copilot_auth_status: CopilotAuthStatus::Starting, }; s.build_provider_order(); s.provider_list.select(Some(0)); @@ -431,6 +460,7 @@ impl State { Step::Welcome => "1 of 7", Step::Migration => "2 of 7", Step::Provider => "3 of 7", + Step::CopilotAuth => "4 of 7", Step::ApiKey => "4 of 7", Step::Model => "5 of 7", Step::Routing => "6 of 7", @@ -610,12 +640,50 @@ pub fn run() -> InitResult { let (test_tx, test_rx) = std::sync::mpsc::channel::(); let (migrate_tx, migrate_rx) = std::sync::mpsc::channel::>(); + let (copilot_tx, copilot_rx) = + std::sync::mpsc::channel::>(); let result = loop { terminal .draw(|f| draw(f, f.area(), &mut state)) .expect("draw failed"); + // Check for Copilot auth events + if state.step == Step::CopilotAuth { + while let Ok(event) = copilot_rx.try_recv() { + match event { + Ok(CopilotAuthEvent::DeviceCode { user_code, verification_uri }) => { + state.copilot_user_code = user_code; + state.copilot_verification_uri = verification_uri; + state.copilot_auth_status = CopilotAuthStatus::WaitingForUser; + } + Ok(CopilotAuthEvent::Authenticated) => { + state.copilot_auth_status = CopilotAuthStatus::FetchingModels; + } + Ok(CopilotAuthEvent::Models(models)) => { + state.copilot_auth_status = CopilotAuthStatus::Done; + state.model_entries.clear(); + for model_id in &models { + state.model_entries.push(ModelEntry { + id: model_id.clone(), + display_name: model_id.clone(), + tier: "copilot", + cost: "free".to_string(), + }); + } + if !state.model_entries.is_empty() { + state.model_list.select(Some(0)); + } + // Auto-advance to model picker + state.step = Step::Model; + } + Err(e) => { + state.copilot_auth_status = CopilotAuthStatus::Failed(e); + } + } + } + } + // Check for background key-test result if state.key_test == KeyTestState::Testing { if let Ok(ok) = test_rx.try_recv() { @@ -750,7 +818,79 @@ pub fn run() -> InitResult { state.selected_provider = Some(prov_idx); let p = &PROVIDERS[prov_idx]; - if !p.needs_key { + if p.name == "github-copilot" { + // Start Copilot device flow in background + state.copilot_auth_status = CopilotAuthStatus::Starting; + state.api_key_from_env = false; + state.step = Step::CopilotAuth; + + // Kick off background auth + let copilot_tx = copilot_tx.clone(); + std::thread::spawn(move || { + let openfang_dir = crate::cli_openfang_home(); + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = copilot_tx.send(Err(format!("Runtime error: {e}"))); + return; + } + }; + + rt.block_on(async { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP error: {e}")); + let http = match http { + Ok(h) => h, + Err(e) => { let _ = copilot_tx.send(Err(e)); return; } + }; + + // Step 1: request device code + use openfang_runtime::drivers::copilot; + let device = match copilot::request_device_code(&http).await { + Ok(d) => d, + Err(e) => { let _ = copilot_tx.send(Err(e)); return; } + }; + + // Send device code to TUI for display + let _ = copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode { + user_code: device.user_code.clone(), + verification_uri: device.verification_uri.clone(), + })); + + // Browser will be opened by user pressing Enter in TUI + + // Step 2: poll for token + let tokens = match copilot::poll_for_token( + &http, + &device.device_code, + device.interval, + ).await { + Ok(t) => t, + Err(e) => { let _ = copilot_tx.send(Err(e)); return; } + }; + + // Save tokens + if let Err(e) = tokens.save(&openfang_dir) { + let _ = copilot_tx.send(Err(e)); + return; + } + + let _ = copilot_tx.send(Ok(CopilotAuthEvent::Authenticated)); + + // Step 3: fetch models + let ct = match copilot::exchange_copilot_token(&http, &tokens.access_token).await { + Ok(ct) => ct, + Err(e) => { let _ = copilot_tx.send(Err(format!("Token exchange: {e}"))); return; } + }; + match copilot::fetch_models(&http, &ct.base_url, &ct.token).await { + Ok(models) => { let _ = copilot_tx.send(Ok(CopilotAuthEvent::Models(models))); } + Err(e) => { let _ = copilot_tx.send(Err(format!("Model fetch: {e}"))); } + } + }); + }); + } else if !p.needs_key { state.api_key_from_env = false; state.load_models_for_provider(); state.step = Step::Model; @@ -769,6 +909,24 @@ pub fn run() -> InitResult { _ => {} }, + Step::CopilotAuth => match key.code { + KeyCode::Esc => { + if matches!(state.copilot_auth_status, CopilotAuthStatus::Failed(_)) { + state.step = Step::Provider; + } + } + KeyCode::Enter => { + if matches!(state.copilot_auth_status, CopilotAuthStatus::WaitingForUser) { + if !state.copilot_verification_uri.is_empty() { + let _ = openfang_runtime::drivers::copilot::open_verification_url( + &state.copilot_verification_uri, + ); + } + } + } + _ => {} + }, + Step::ApiKey => { if matches!(state.key_test, KeyTestState::Ok | KeyTestState::Warn) { continue; @@ -1242,6 +1400,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) { Step::Welcome => draw_welcome(f, chunks[3]), Step::Migration => draw_migration(f, chunks[3], state), Step::Provider => draw_provider(f, chunks[3], state), + Step::CopilotAuth => draw_copilot_auth(f, chunks[3], state), Step::ApiKey => draw_api_key(f, chunks[3], state), Step::Model => draw_model(f, chunks[3], state), Step::Routing => draw_routing(f, chunks[3], state), @@ -1743,6 +1902,12 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) { } else { "no API key needed".to_string() } + } else if p.name == "github-copilot" { + if detected { + format!("{} detected", p.env_var) + } else { + "run set-key after init".to_string() + } } else if detected { format!("{} detected", p.env_var) } else if !p.needs_key { @@ -1772,6 +1937,109 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) { f.render_widget(hints, chunks[2]); } +fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) { + let chunks = Layout::vertical([ + Constraint::Length(2), // title + Constraint::Length(1), // blank + Constraint::Length(1), // status line 1 + Constraint::Length(1), // status line 2 + Constraint::Length(1), // blank + Constraint::Length(1), // code label + Constraint::Length(1), // code value + Constraint::Length(1), // blank + Constraint::Length(1), // url + Constraint::Min(0), // spacer + Constraint::Length(1), // hint + ]) + .split(area); + + let title = Paragraph::new(Line::from(vec![ + Span::styled(" GitHub Copilot Authentication", Style::default().fg(theme::ACCENT)), + ])); + f.render_widget(title, chunks[0]); + + let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()]; + + match &state.copilot_auth_status { + CopilotAuthStatus::Starting => { + let line = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(spinner, Style::default().fg(theme::ACCENT)), + Span::raw(" Requesting device code..."), + ])); + f.render_widget(line, chunks[2]); + } + CopilotAuthStatus::WaitingForUser => { + let line1 = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(spinner, Style::default().fg(theme::ACCENT)), + Span::raw(" Waiting for authorization..."), + ])); + f.render_widget(line1, chunks[2]); + + let code_label = Paragraph::new(Line::from(vec![ + Span::raw(" Enter this code:"), + ])); + f.render_widget(code_label, chunks[5]); + + let code_value = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled( + &state.copilot_user_code, + Style::default() + .fg(theme::GREEN) + .add_modifier(Modifier::BOLD), + ), + ])); + f.render_widget(code_value, chunks[6]); + + let url = Paragraph::new(Line::from(vec![ + Span::raw(" at "), + Span::styled(&state.copilot_verification_uri, theme::dim_style()), + ])); + f.render_widget(url, chunks[8]); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled(" [Enter] Open browser", theme::dim_style()), + ])); + f.render_widget(hint, chunks[10]); + } + CopilotAuthStatus::FetchingModels => { + let line = Paragraph::new(Line::from(vec![ + Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)), + Span::raw("Authenticated"), + ])); + f.render_widget(line, chunks[2]); + + let line2 = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(spinner, Style::default().fg(theme::ACCENT)), + Span::raw(" Fetching available models..."), + ])); + f.render_widget(line2, chunks[3]); + } + CopilotAuthStatus::Done => { + let line = Paragraph::new(Line::from(vec![ + Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)), + Span::raw("Models loaded"), + ])); + f.render_widget(line, chunks[2]); + } + CopilotAuthStatus::Failed(err) => { + let line = Paragraph::new(Line::from(vec![ + Span::styled(" \u{2718} ", Style::default().fg(theme::RED)), + Span::raw(err.as_str()), + ])); + f.render_widget(line, chunks[2]); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled(" Esc to go back", theme::dim_style()), + ])); + f.render_widget(hint, chunks[10]); + } + } +} + fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) { let p = match state.provider() { Some(p) => p, diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index cc070a15..d039c387 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -509,6 +509,39 @@ impl OpenFangKernel { Self::boot_with_config(config) } + /// Fetch live Copilot models by exchanging the persisted token and querying the API. + /// Works both inside and outside a tokio runtime. + fn fetch_copilot_models(openfang_dir: &Path) -> Result, String> { + use openfang_runtime::drivers::copilot; + + let tokens = copilot::PersistedTokens::load(&openfang_dir.to_path_buf()) + .ok_or("No persisted Copilot tokens found")?; + + let fetch = async { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let ct = copilot::exchange_copilot_token(&http, &tokens.access_token).await?; + copilot::fetch_models(&http, &ct.base_url, &ct.token).await + }; + + // If we're already inside a tokio runtime (daemon start), use the existing one. + // Otherwise (CLI commands), create a new one. + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::thread::scope(|s| { + s.spawn(|| { + handle.block_on(fetch) + }).join().unwrap_or(Err("Thread panicked".to_string())) + }) + } else { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| format!("Failed to create runtime: {e}"))?; + rt.block_on(fetch) + } + } + /// Boot the kernel with an explicit configuration. pub fn boot_with_config(mut config: KernelConfig) -> KernelResult { use openfang_types::config::KernelMode; @@ -746,6 +779,21 @@ impl OpenFangKernel { // Load user's custom models from ~/.openfang/custom_models.json let custom_models_path = config.home_dir.join("custom_models.json"); model_catalog.load_custom_models(&custom_models_path); + + // Fetch live Copilot models if authenticated + if openfang_runtime::drivers::copilot::copilot_auth_available(&config.home_dir) { + let copilot_dir = config.home_dir.clone(); + match Self::fetch_copilot_models(&copilot_dir) { + Ok(models) => { + info!(count = models.len(), "Fetched live Copilot model catalog"); + model_catalog.merge_discovered_models("github-copilot", &models); + } + Err(e) => { + warn!("Failed to fetch Copilot models (will use static catalog): {e}"); + } + } + } + let available_count = model_catalog.available_models().len(); let total_count = model_catalog.list_models().len(); let local_count = model_catalog diff --git a/crates/openfang-runtime/src/drivers/copilot.rs b/crates/openfang-runtime/src/drivers/copilot.rs index 44cb3e22..8df53c64 100644 --- a/crates/openfang-runtime/src/drivers/copilot.rs +++ b/crates/openfang-runtime/src/drivers/copilot.rs @@ -1,93 +1,345 @@ -//! GitHub Copilot authentication — exchanges a GitHub PAT for a Copilot API token. +//! GitHub Copilot LLM driver with full OAuth device flow authentication. //! -//! The Copilot API uses the OpenAI chat completions format, so this module -//! handles token exchange and caching, then delegates to the OpenAI-compatible driver. +//! Implements the complete Copilot auth chain: +//! 1. OAuth device flow (user registers their own GitHub OAuth App) +//! 2. `ghu_` access token with `grt_` refresh token (auto-refresh) +//! 3. Copilot API token exchange via `/copilot_internal/v2/token` +//! 4. OpenAI-compatible completions against the Copilot API +//! +//! Users configure their OAuth App's `client_id` and `client_secret` in +//! `config.toml`. The driver handles the rest — device flow, token persistence, +//! refresh, and Copilot API token exchange — automatically. +use std::path::PathBuf; use std::sync::Mutex; -use std::time::{Duration, Instant}; -use tracing::{debug, warn}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; use zeroize::Zeroizing; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + /// Copilot token exchange endpoint. const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; -/// Token exchange timeout. -const TOKEN_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10); +/// GitHub device code endpoint. +const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; -/// Refresh buffer — refresh token this many seconds before expiry. -const REFRESH_BUFFER_SECS: u64 = 300; // 5 minutes +/// GitHub OAuth access token endpoint. +const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; -/// Default Copilot API base URL. +/// GitHub Copilot's own OAuth App client ID (VS Code Copilot extension). +/// This is a public, well-known client ID used by all direct Copilot integrations. +/// Only tokens from this app (`ghu_` prefix) are accepted by copilot_internal. +const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; + +/// Default Copilot API base URL (overridden by `endpoints.api` from token exchange). pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; -/// Cached Copilot API token with expiry and derived base URL. +/// HTTP timeout for token exchange requests. +const TOKEN_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Refresh Copilot API token this many seconds before it expires. +const COPILOT_TOKEN_REFRESH_BUFFER_SECS: u64 = 300; // 5 minutes + +/// Refresh `ghu_` access token this many seconds before it expires. +const ACCESS_TOKEN_REFRESH_BUFFER_SECS: u64 = 600; // 10 minutes + +/// Scopes requested during device flow. +const OAUTH_SCOPES: &str = "copilot"; + +/// File name for persisted OAuth tokens (inside ~/.openfang/). +const TOKEN_FILE_NAME: &str = ".copilot-tokens.json"; + +/// Device flow polling interval (seconds) — GitHub default is 5. +const DEVICE_FLOW_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Maximum time to wait for user to authorize the device flow. +const DEVICE_FLOW_TIMEOUT: Duration = Duration::from_secs(900); // 15 minutes + +// --------------------------------------------------------------------------- +// Persisted OAuth tokens (ghu_ + grt_) +// --------------------------------------------------------------------------- + +/// OAuth tokens persisted to disk for survival across daemon restarts. +#[derive(Clone, Serialize, Deserialize)] +pub struct PersistedTokens { + /// `ghu_` user access token. + pub access_token: String, + /// Unix timestamp when `access_token` expires. + pub access_token_expires_at: i64, + /// `grt_` refresh token (single-use, rotated on each refresh). + pub refresh_token: String, +} + +impl PersistedTokens { + /// Whether the access token is still usable (with buffer). + fn access_token_valid(&self) -> bool { + let now = unix_now(); + self.access_token_expires_at > now + ACCESS_TOKEN_REFRESH_BUFFER_SECS as i64 + } + + /// Load from the OpenFang data directory. + pub fn load(openfang_dir: &PathBuf) -> Option { + let path = openfang_dir.join(TOKEN_FILE_NAME); + let data = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&data).ok() + } + + /// Persist to the OpenFang data directory with restricted permissions. + pub fn save(&self, openfang_dir: &PathBuf) -> Result<(), String> { + let path = openfang_dir.join(TOKEN_FILE_NAME); + let json = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize tokens: {e}"))?; + std::fs::write(&path, &json) + .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + + // Restrict file permissions on Unix. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&path, perms); + } + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Cached Copilot API token (tid=…) +// --------------------------------------------------------------------------- + +/// Short-lived Copilot API token from `/copilot_internal/v2/token`. #[derive(Clone)] -pub struct CachedToken { - /// The Copilot API token (zeroized on drop). +pub struct CachedCopilotToken { + /// The bearer token for Copilot API requests. pub token: Zeroizing, /// When this token expires. pub expires_at: Instant, - /// Base URL derived from proxy-ep in the token (or default). + /// Base URL from `endpoints.api` (plan-specific). pub base_url: String, } -impl CachedToken { - /// Check if the token is still valid (with refresh buffer). - pub fn is_valid(&self) -> bool { - self.expires_at > Instant::now() + Duration::from_secs(REFRESH_BUFFER_SECS) +impl CachedCopilotToken { + fn is_valid(&self) -> bool { + self.expires_at > Instant::now() + Duration::from_secs(COPILOT_TOKEN_REFRESH_BUFFER_SECS) } } -/// Thread-safe token cache for a single Copilot session. -pub struct CopilotTokenCache { - cached: Mutex>, +// --------------------------------------------------------------------------- +// Cached model list +// --------------------------------------------------------------------------- + +/// Cached list of available models from the Copilot API. +#[derive(Clone)] +struct CachedModels { + models: Vec, + fetched_at: Instant, } -impl CopilotTokenCache { - pub fn new() -> Self { - Self { - cached: Mutex::new(None), +// --------------------------------------------------------------------------- +// OAuth device flow +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + #[serde(default = "default_interval")] + pub interval: u64, + #[allow(dead_code)] + pub expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Deserialize)] +struct OAuthTokenResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, + #[serde(default)] + refresh_token_expires_in: Option, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +/// Request a device code from GitHub using the Copilot client ID. +pub async fn request_device_code( + client: &reqwest::Client, +) -> Result { + let resp = client + .post(GITHUB_DEVICE_CODE_URL) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "client_id": COPILOT_CLIENT_ID, + "scope": OAUTH_SCOPES, + })) + .send() + .await + .map_err(|e| format!("Device code request failed: {e}"))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Device code request returned error: {body}")); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse device code response: {e}")) +} + +/// Poll for the OAuth token after user authorizes. +pub async fn poll_for_token( + client: &reqwest::Client, + device_code: &str, + interval: u64, +) -> Result { + let poll_interval = Duration::from_secs(interval.max(5)); + let deadline = Instant::now() + DEVICE_FLOW_TIMEOUT; + + loop { + if Instant::now() > deadline { + return Err("Device flow timed out — user did not authorize in time".to_string()); } - } - /// Get a valid cached token, or None if expired/missing. - pub fn get(&self) -> Option { - let lock = self.cached.lock().unwrap_or_else(|e| e.into_inner()); - lock.as_ref().filter(|t| t.is_valid()).cloned() - } + tokio::time::sleep(poll_interval).await; - /// Store a new token in the cache. - pub fn set(&self, token: CachedToken) { - let mut lock = self.cached.lock().unwrap_or_else(|e| e.into_inner()); - *lock = Some(token); + let resp = client + .post(GITHUB_TOKEN_URL) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "client_id": COPILOT_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + })) + .send() + .await + .map_err(|e| format!("Token poll failed: {e}"))?; + + let token_resp: OAuthTokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse token poll response: {e}"))?; + + if let Some(ref err) = token_resp.error { + match err.as_str() { + "authorization_pending" => continue, + "slow_down" => { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + "expired_token" => { + return Err("Device code expired — please try again".to_string()); + } + "access_denied" => { + return Err("User denied authorization".to_string()); + } + _ => { + let desc = token_resp.error_description.as_deref().unwrap_or(""); + return Err(format!("OAuth error: {err} — {desc}")); + } + } + } + + let access_token = token_resp + .access_token + .ok_or("Missing access_token in response")?; + let refresh_token = token_resp + .refresh_token + .unwrap_or_default(); // Empty if token expiration is disabled on the OAuth App + let expires_in = token_resp.expires_in.unwrap_or(0); // 0 = non-expiring + + return Ok(PersistedTokens { + access_token, + access_token_expires_at: if expires_in > 0 { + unix_now() + expires_in + } else { + // Non-expiring token — set far-future expiry. + unix_now() + 365 * 24 * 3600 + }, + refresh_token, + }); } } -impl Default for CopilotTokenCache { - fn default() -> Self { - Self::new() +/// Refresh an expired `ghu_` token using the `grt_` refresh token. +async fn refresh_access_token( + client: &reqwest::Client, + refresh_token: &str, +) -> Result { + debug!("Refreshing Copilot access token via refresh_token"); + + let resp = client + .post(GITHUB_TOKEN_URL) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "client_id": COPILOT_CLIENT_ID, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + })) + .send() + .await + .map_err(|e| format!("Token refresh request failed: {e}"))?; + + let token_resp: OAuthTokenResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse refresh response: {e}"))?; + + if let Some(ref err) = token_resp.error { + let desc = token_resp.error_description.as_deref().unwrap_or(""); + return Err(format!("Token refresh failed: {err} — {desc}")); } + + let access_token = token_resp + .access_token + .ok_or("Missing access_token in refresh response")?; + let new_refresh_token = token_resp + .refresh_token + .ok_or("Missing refresh_token in refresh response")?; + let expires_in = token_resp.expires_in.unwrap_or(28800); + + Ok(PersistedTokens { + access_token, + access_token_expires_at: unix_now() + expires_in, + refresh_token: new_refresh_token, + }) } -/// Exchange a GitHub PAT for a Copilot API token. -/// -/// POST https://api.github.com/copilot_internal/v2/token -/// Authorization: Bearer {github_token} -/// -/// Response: {"token": "tid=...;exp=...;sku=...;proxy-ep=...", "expires_at": unix_timestamp} -pub async fn exchange_copilot_token(github_token: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(TOKEN_EXCHANGE_TIMEOUT) - .build() - .map_err(|e| format!("Failed to build HTTP client: {e}"))?; +// --------------------------------------------------------------------------- +// Copilot API token exchange +// --------------------------------------------------------------------------- - debug!("Exchanging GitHub token for Copilot API token"); +/// Exchange a `ghu_` access token for a short-lived Copilot API token. +pub async fn exchange_copilot_token( + client: &reqwest::Client, + access_token: &str, +) -> Result { + debug!("Exchanging access token for Copilot API token"); let resp = client .get(COPILOT_TOKEN_URL) - .header("Authorization", format!("token {github_token}")) + .header("Authorization", format!("token {access_token}")) .header("Accept", "application/json") .header("User-Agent", "OpenFang/1.0") + .header("Editor-Version", "vscode/1.96.0") + .header("Editor-Plugin-Version", "copilot/1.250.0") .send() .await .map_err(|e| format!("Copilot token exchange failed: {e}"))?; @@ -109,114 +361,319 @@ pub async fn exchange_copilot_token(github_token: &str) -> Result). -pub fn parse_copilot_token(raw: &str) -> (String, Option) { - let mut proxy_ep = None; - +/// Extract `proxy-ep` from the semicolon-delimited Copilot token string. +fn parse_proxy_ep(raw: &str) -> Option { for segment in raw.split(';') { - let segment = segment.trim(); - if let Some(url) = segment.strip_prefix("proxy-ep=") { - proxy_ep = Some(url.to_string()); + if let Some(url) = segment.trim().strip_prefix("proxy-ep=") { + if url.contains('.') { + return Some(if url.starts_with("https://") { + url.to_string() + } else { + format!("https://{url}") + }); + } } } - - (raw.to_string(), proxy_ep) + None } -/// Check if GitHub Copilot auth is available (GITHUB_TOKEN env var is set). -pub fn copilot_auth_available() -> bool { - std::env::var("GITHUB_TOKEN").is_ok() +// --------------------------------------------------------------------------- +// Model list fetching +// --------------------------------------------------------------------------- + +/// Fetch available models from the Copilot API. +pub async fn fetch_models( + client: &reqwest::Client, + base_url: &str, + copilot_token: &str, +) -> Result, String> { + debug!(base_url = %base_url, "Fetching available Copilot models"); + + let url = format!("{}/models", base_url.trim_end_matches('/')); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {copilot_token}")) + .header("User-Agent", "OpenFang/1.0") + .header("Editor-Version", "vscode/1.96.0") + .send() + .await + .map_err(|e| format!("Model list fetch failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Model list request returned {status}: {body}")); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse model list: {e}"))?; + + let models: Vec = body + .get("data") + .or_else(|| body.get("models")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + debug!(count = models.len(), "Loaded Copilot model catalog"); + Ok(models) } -/// LLM driver that wraps OpenAI-compatible with Copilot token exchange. -/// -/// On each API call, ensures a valid Copilot API token is available -/// (exchanging the GitHub PAT if needed), then delegates to an OpenAI-compatible driver. +// --------------------------------------------------------------------------- +// CopilotDriver +// --------------------------------------------------------------------------- + +/// LLM driver that authenticates via GitHub OAuth device flow and proxies +/// completions through the Copilot API (OpenAI-compatible). pub struct CopilotDriver { - github_token: Zeroizing, - token_cache: CopilotTokenCache, + openfang_dir: PathBuf, + http_client: reqwest::Client, + + /// Persisted OAuth tokens (ghu_ + grt_). + oauth_tokens: Mutex>, + /// Cached short-lived Copilot API token. + copilot_token: Mutex>, + /// Cached model list. + models: Mutex>, } impl CopilotDriver { - pub fn new(github_token: String, _base_url: String) -> Self { + pub fn new(openfang_dir: PathBuf) -> Self { + let http_client = reqwest::Client::builder() + .timeout(TOKEN_EXCHANGE_TIMEOUT) + .build() + .expect("Failed to build HTTP client"); + + // Try to load persisted tokens on construction. + let persisted = PersistedTokens::load(&openfang_dir); + if persisted.is_some() { + debug!("Loaded persisted Copilot OAuth tokens"); + } + Self { - github_token: Zeroizing::new(github_token), - token_cache: CopilotTokenCache::new(), + openfang_dir, + http_client, + oauth_tokens: Mutex::new(persisted), + copilot_token: Mutex::new(None), + models: Mutex::new(None), } } - /// Get a valid Copilot API token, exchanging if needed. - async fn ensure_token(&self) -> Result { - // Check cache first - if let Some(cached) = self.token_cache.get() { - return Ok(cached); + /// Ensure we have a valid `ghu_` access token, refreshing or re-authing as needed. + async fn ensure_access_token(&self) -> Result { + // Check if current token is still valid. + { + let lock = self.oauth_tokens.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ref tokens) = *lock { + if tokens.access_token_valid() { + return Ok(tokens.access_token.clone()); + } + } } - // Exchange GitHub PAT for Copilot token - debug!("Copilot token expired or missing, exchanging..."); - let token = exchange_copilot_token(&self.github_token) + // Try to refresh using the refresh token (only if one exists). + let refresh_token = { + let lock = self.oauth_tokens.lock().unwrap_or_else(|e| e.into_inner()); + lock.as_ref() + .map(|t| t.refresh_token.clone()) + .filter(|rt| !rt.is_empty()) + }; + + if let Some(ref rt) = refresh_token { + match refresh_access_token( + &self.http_client, + rt, + ) + .await + { + Ok(new_tokens) => { + info!("Copilot access token refreshed successfully"); + if let Err(e) = new_tokens.save(&self.openfang_dir) { + warn!("Failed to persist refreshed tokens: {e}"); + } + let access_token = new_tokens.access_token.clone(); + let mut lock = self.oauth_tokens.lock().unwrap_or_else(|e| e.into_inner()); + *lock = Some(new_tokens); + // Invalidate copilot token cache since access token changed. + let mut ct_lock = self.copilot_token.lock().unwrap_or_else(|e| e.into_inner()); + *ct_lock = None; + return Ok(access_token); + } + Err(e) => { + warn!("Token refresh failed, device flow re-auth required: {e}"); + } + } + } + + // No valid tokens and refresh failed — need device flow. + // In daemon mode, we can't do interactive auth. Return a clear error. + Err(crate::llm_driver::LlmError::AuthenticationFailed( + "Copilot OAuth tokens expired. Run `openfang config set-key github-copilot` to re-authenticate via device flow.".to_string(), + )) + } + + /// Ensure we have a valid Copilot API token (tid=…). + async fn ensure_copilot_token(&self) -> Result + { + // Check cache. + { + let lock = self.copilot_token.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ref ct) = *lock { + if ct.is_valid() { + return Ok(ct.clone()); + } + } + } + + // Get a valid access token first. + let access_token = self.ensure_access_token().await?; + + // Exchange for Copilot API token. + let ct = exchange_copilot_token(&self.http_client, &access_token) .await .map_err(|e| crate::llm_driver::LlmError::Api { status: 401, message: format!("Copilot token exchange failed: {e}"), })?; - self.token_cache.set(token.clone()); - Ok(token) + let mut lock = self.copilot_token.lock().unwrap_or_else(|e| e.into_inner()); + *lock = Some(ct.clone()); + Ok(ct) } - /// Create a fresh OpenAI driver with the current Copilot token. - fn make_inner_driver(&self, token: &CachedToken) -> super::openai::OpenAIDriver { - // Use proxy-ep from token if available, otherwise fall back to default base URL. - let base_url = if token.base_url.is_empty() { + /// Ensure model list is cached; fetch if missing. + async fn ensure_models( + &self, + copilot_token: &CachedCopilotToken, + ) -> Result, crate::llm_driver::LlmError> { + { + let lock = self.models.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ref cached) = *lock { + return Ok(cached.models.clone()); + } + } + + self.refresh_models(copilot_token).await + } + + /// Force-refresh the model list from the API. + async fn refresh_models( + &self, + copilot_token: &CachedCopilotToken, + ) -> Result, crate::llm_driver::LlmError> { + let models = + fetch_models(&self.http_client, &copilot_token.base_url, &copilot_token.token) + .await + .map_err(|e| crate::llm_driver::LlmError::Api { + status: 500, + message: format!("Failed to fetch model list: {e}"), + })?; + + let mut lock = self.models.lock().unwrap_or_else(|e| e.into_inner()); + *lock = Some(CachedModels { + models: models.clone(), + fetched_at: Instant::now(), + }); + Ok(models) + } + + /// Build an OpenAI driver for the current Copilot token. + fn make_inner_driver(&self, ct: &CachedCopilotToken) -> super::openai::OpenAIDriver { + let base_url = if ct.base_url.is_empty() { GITHUB_COPILOT_BASE_URL.to_string() } else { - token.base_url.clone() + ct.base_url.clone() }; - super::openai::OpenAIDriver::new(token.token.to_string(), base_url).with_extra_headers( - vec![ - ("Editor-Version".to_string(), "vscode/1.96.0".to_string()), - ( - "Editor-Plugin-Version".to_string(), - "copilot/1.250.0".to_string(), - ), - ( - "Copilot-Integration-Id".to_string(), - "vscode-chat".to_string(), - ), - ], - ) + super::openai::OpenAIDriver::new(ct.token.to_string(), base_url).with_extra_headers(vec![ + ("Editor-Version".to_string(), "vscode/1.96.0".to_string()), + ( + "Editor-Plugin-Version".to_string(), + "copilot/1.250.0".to_string(), + ), + ( + "Copilot-Integration-Id".to_string(), + "vscode-chat".to_string(), + ), + ]) + } + + /// Execute a request, retrying once on model_not_supported after refreshing models. + async fn execute_with_model_retry( + &self, + request: crate::llm_driver::CompletionRequest, + execute: F, + ) -> Result + where + F: Fn( + super::openai::OpenAIDriver, + crate::llm_driver::CompletionRequest, + ) -> Fut, + Fut: std::future::Future< + Output = Result, + >, + { + let ct = self.ensure_copilot_token().await?; + let _ = self.ensure_models(&ct).await; // best-effort cache + let driver = self.make_inner_driver(&ct); + + match execute(driver, request.clone()).await { + Ok(resp) => Ok(resp), + Err(crate::llm_driver::LlmError::Api { status, ref message }) + if status == 400 && message.contains("model_not_supported") => + { + // Refresh model list so subsequent calls have updated info. + warn!( + model = %request.model, + "Model not supported — refreshing model catalog" + ); + if let Ok(models) = self.refresh_models(&ct).await { + let available = models.join(", "); + return Err(crate::llm_driver::LlmError::ModelNotFound(format!( + "'{}' is not available. Models: {available}", + request.model + ))); + } + Err(crate::llm_driver::LlmError::ModelNotFound(format!( + "'{}' is not supported by your Copilot plan", + request.model + ))) + } + Err(e) => Err(e), + } } } @@ -226,9 +683,10 @@ impl crate::llm_driver::LlmDriver for CopilotDriver { &self, request: crate::llm_driver::CompletionRequest, ) -> Result { - let token = self.ensure_token().await?; - let driver = self.make_inner_driver(&token); - driver.complete(request).await + self.execute_with_model_retry(request, |driver, req| async move { + driver.complete(req).await + }) + .await } async fn stream( @@ -236,77 +694,234 @@ impl crate::llm_driver::LlmDriver for CopilotDriver { request: crate::llm_driver::CompletionRequest, tx: tokio::sync::mpsc::Sender, ) -> Result { - let token = self.ensure_token().await?; - let driver = self.make_inner_driver(&token); - driver.stream(request, tx).await + let ct = self.ensure_copilot_token().await?; + let _ = self.ensure_models(&ct).await; + let driver = self.make_inner_driver(&ct); + + match driver.stream(request.clone(), tx.clone()).await { + Ok(resp) => Ok(resp), + Err(crate::llm_driver::LlmError::Api { status, ref message }) + if status == 400 && message.contains("model_not_supported") => + { + warn!( + model = %request.model, + "Model not supported — refreshing model catalog" + ); + if let Ok(models) = self.refresh_models(&ct).await { + let available = models.join(", "); + return Err(crate::llm_driver::LlmError::ModelNotFound(format!( + "'{}' is not available. Models: {available}", + request.model + ))); + } + Err(crate::llm_driver::LlmError::ModelNotFound(format!( + "'{}' is not supported by your Copilot plan", + request.model + ))) + } + Err(e) => Err(e), + } } } +// --------------------------------------------------------------------------- +// Interactive device flow (called from CLI, not from daemon) +// --------------------------------------------------------------------------- + +/// Run the interactive Copilot setup: execute the device flow. +/// +/// Called from `openfang config set-key github-copilot`, `openfang init`, +/// `openfang onboard`, and `openfang configure`. +pub async fn run_interactive_setup( + openfang_dir: &PathBuf, +) -> Result { + run_device_flow(openfang_dir).await +} + +/// Run the OAuth device flow using the Copilot client ID. +/// +/// Prints the user code and verification URL, attempts to open the browser, +/// then polls until the user authorizes. +pub async fn run_device_flow( + openfang_dir: &PathBuf, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + + // Step 1: Request device code. + let device = request_device_code(&client).await?; + + // Step 2: Tell the user what to do + try to open browser. + println!(); + println!(" Open this URL in your browser:"); + println!(" {}", device.verification_uri); + println!(); + println!(" Enter code: {}", device.user_code); + println!(); + + // Try to open the browser automatically. + let _ = open_verification_url(&device.verification_uri); + + println!(" Waiting for authorization..."); + + // Step 3: Poll for authorization. + let tokens = poll_for_token( + &client, + &device.device_code, + device.interval, + ) + .await?; + + // Step 4: Persist. + tokens.save(openfang_dir)?; + println!(" Copilot authentication successful."); + + Ok(tokens) +} + +/// Read a line from stdin with a prompt. Used during interactive setup. +fn prompt_line(prompt: &str) -> Result { + use std::io::{self, BufRead, Write}; + print!("{prompt}"); + io::stdout().flush().map_err(|e| format!("IO error: {e}"))?; + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| format!("Failed to read input: {e}"))?; + Ok(line.trim().to_string()) +} + +/// Try to open the verification URL in the default browser. +pub fn open_verification_url(url: &str) -> bool { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(url).spawn().is_ok() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + false + } +} + +/// Check if Copilot OAuth tokens exist on disk. +pub fn copilot_auth_available(openfang_dir: &PathBuf) -> bool { + openfang_dir.join(TOKEN_FILE_NAME).exists() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; #[test] - fn test_parse_copilot_token_with_proxy() { - let raw = "tid=abc123;exp=1700000000;sku=copilot_for_individual;proxy-ep=https://copilot-proxy.example.com"; - let (token, proxy) = parse_copilot_token(raw); - assert_eq!(token, raw); - assert_eq!(proxy, Some("https://copilot-proxy.example.com".to_string())); + fn test_parse_proxy_ep_with_https() { + let raw = "tid=abc;exp=123;proxy-ep=https://proxy.enterprise.githubcopilot.com"; + assert_eq!( + parse_proxy_ep(raw), + Some("https://proxy.enterprise.githubcopilot.com".to_string()) + ); } #[test] - fn test_parse_copilot_token_without_proxy() { - let raw = "tid=abc123;exp=1700000000;sku=copilot_for_individual"; - let (token, proxy) = parse_copilot_token(raw); - assert_eq!(token, raw); - assert!(proxy.is_none()); + fn test_parse_proxy_ep_bare_hostname() { + let raw = "tid=abc;exp=123;proxy-ep=proxy.enterprise.githubcopilot.com"; + assert_eq!( + parse_proxy_ep(raw), + Some("https://proxy.enterprise.githubcopilot.com".to_string()) + ); } #[test] - fn test_parse_copilot_token_simple() { - let raw = "just-a-token"; - let (token, proxy) = parse_copilot_token(raw); - assert_eq!(token, raw); - assert!(proxy.is_none()); + fn test_parse_proxy_ep_missing() { + let raw = "tid=abc;exp=123;sku=copilot_enterprise"; + assert_eq!(parse_proxy_ep(raw), None); } #[test] - fn test_token_cache_empty() { - let cache = CopilotTokenCache::new(); - assert!(cache.get().is_none()); - } - - #[test] - fn test_token_cache_set_get() { - let cache = CopilotTokenCache::new(); - let token = CachedToken { - token: Zeroizing::new("test-token".to_string()), - expires_at: Instant::now() + Duration::from_secs(3600), - base_url: GITHUB_COPILOT_BASE_URL.to_string(), + fn test_persisted_tokens_validity() { + let valid = PersistedTokens { + access_token: "ghu_test".to_string(), + access_token_expires_at: unix_now() + 7200, // 2h from now + refresh_token: "grt_test".to_string(), }; - cache.set(token); - let cached = cache.get(); - assert!(cached.is_some()); - assert_eq!(*cached.unwrap().token, "test-token"); + assert!(valid.access_token_valid()); + + let expired = PersistedTokens { + access_token: "ghu_test".to_string(), + access_token_expires_at: unix_now() + 60, // 1 min — within buffer + refresh_token: "grt_test".to_string(), + }; + assert!(!expired.access_token_valid()); } #[test] - fn test_token_validity_check() { - // Valid token (expires in 1 hour) - let valid = CachedToken { - token: Zeroizing::new("t".to_string()), + fn test_persisted_tokens_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_path_buf(); + + let tokens = PersistedTokens { + access_token: "ghu_abc123".to_string(), + access_token_expires_at: unix_now() + 3600, + refresh_token: "grt_xyz789".to_string(), + }; + + tokens.save(&path).unwrap(); + + let loaded = PersistedTokens::load(&path).unwrap(); + assert_eq!(loaded.access_token, "ghu_abc123"); + assert_eq!(loaded.refresh_token, "grt_xyz789"); + } + + #[test] + fn test_cached_copilot_token_validity() { + let valid = CachedCopilotToken { + token: Zeroizing::new("tid=test".to_string()), expires_at: Instant::now() + Duration::from_secs(3600), base_url: GITHUB_COPILOT_BASE_URL.to_string(), }; assert!(valid.is_valid()); - // Token that expires in < 5 min should be considered expired - let almost_expired = CachedToken { - token: Zeroizing::new("t".to_string()), + let expired = CachedCopilotToken { + token: Zeroizing::new("tid=test".to_string()), expires_at: Instant::now() + Duration::from_secs(60), base_url: GITHUB_COPILOT_BASE_URL.to_string(), }; - assert!(!almost_expired.is_valid()); + assert!(!expired.is_valid()); } #[test] diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs index 2df8923d..29561261 100644 --- a/crates/openfang-runtime/src/drivers/mod.rs +++ b/crates/openfang-runtime/src/drivers/mod.rs @@ -139,8 +139,8 @@ fn provider_defaults(provider: &str) -> Option { }), "github-copilot" | "copilot" => Some(ProviderDefaults { base_url: copilot::GITHUB_COPILOT_BASE_URL, - api_key_env: "GITHUB_TOKEN", - key_required: true, + api_key_env: "COPILOT_CLIENT_ID", + key_required: false, // Auth handled via OAuth device flow, not simple API key }), "codex" | "openai-codex" => Some(ProviderDefaults { base_url: OPENAI_BASE_URL, @@ -334,27 +334,23 @@ pub fn create_driver(config: &DriverConfig) -> Result, LlmErr ))); } - // GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange. - // The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand, - // caches it, and refreshes when expired. + // GitHub Copilot — OAuth device flow + OpenAI-compatible completions. + // Authentication is handled automatically via persisted tokens from the device flow. + // Run `openfang config set-key github-copilot` to authenticate. if provider == "github-copilot" || provider == "copilot" { - let github_token = config - .api_key - .clone() - .or_else(|| std::env::var("GITHUB_TOKEN").ok()) - .ok_or_else(|| { - LlmError::MissingApiKey( - "Set GITHUB_TOKEN environment variable for GitHub Copilot".to_string(), - ) - })?; - let base_url = config - .base_url - .clone() - .unwrap_or_else(|| copilot::GITHUB_COPILOT_BASE_URL.to_string()); - return Ok(Arc::new(copilot::CopilotDriver::new( - github_token, - base_url, - ))); + let openfang_dir = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(|h| std::path::PathBuf::from(h).join(".openfang")) + .unwrap_or_else(|_| std::path::PathBuf::from(".openfang")); + + if !copilot::copilot_auth_available(&openfang_dir) { + return Err(LlmError::MissingApiKey( + "Copilot not authenticated. Run `openfang config set-key github-copilot` to sign in." + .to_string(), + )); + } + + return Ok(Arc::new(copilot::CopilotDriver::new(openfang_dir))); } // Azure OpenAI — deployment-based URL with `api-key` header diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index c97bfb5c..efe7b735 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -75,6 +75,21 @@ impl ModelCatalog { continue; } + // GitHub Copilot: check for persisted OAuth tokens + if provider.id == "github-copilot" || provider.id == "copilot" { + let openfang_dir = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(|h| std::path::PathBuf::from(h).join(".openfang")) + .unwrap_or_else(|_| std::path::PathBuf::from(".openfang")); + provider.auth_status = + if crate::drivers::copilot::copilot_auth_available(&openfang_dir) { + AuthStatus::Configured + } else { + AuthStatus::Missing + }; + continue; + } + if !provider.key_required { provider.auth_status = AuthStatus::NotRequired; continue; @@ -961,11 +976,10 @@ fn builtin_aliases() -> HashMap { ("command-r", "command-r-plus"), ("command", "command-a"), // GitHub Copilot aliases - ("copilot", "copilot/gpt-4o"), - ("copilot-4o", "copilot/gpt-4o"), - ("copilot-4", "copilot/gpt-4"), - ("copilot-gpt4o", "copilot/gpt-4o"), - ("copilot-gpt4", "copilot/gpt-4"), + ("copilot", "gpt-4o"), + ("copilot-4o", "gpt-4o"), + ("copilot-opus", "claude-opus-4.6"), + ("copilot-sonnet", "claude-sonnet-4.6"), // Chinese model aliases ("qwen", "qwen-plus"), ("glm", "glm-5-20250605"), @@ -2904,36 +2918,9 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // GitHub Copilot (2) — free for subscribers + // GitHub Copilot — models fetched dynamically at runtime. + // No static entries needed; see kernel.rs fetch_copilot_models(). // ══════════════════════════════════════════════════════════════ - ModelCatalogEntry { - id: "copilot/gpt-4o".into(), - display_name: "GPT-4o (Copilot)".into(), - provider: "github-copilot".into(), - tier: ModelTier::Smart, - context_window: 128_000, - max_output_tokens: 4_096, - input_cost_per_m: 0.0, - output_cost_per_m: 0.0, - supports_tools: true, - supports_vision: true, - supports_streaming: true, - aliases: vec!["copilot-gpt4o".into()], - }, - ModelCatalogEntry { - id: "copilot/gpt-4".into(), - display_name: "GPT-4 (Copilot)".into(), - provider: "github-copilot".into(), - tier: ModelTier::Frontier, - context_window: 128_000, - max_output_tokens: 4_096, - input_cost_per_m: 0.0, - output_cost_per_m: 0.0, - supports_tools: true, - supports_vision: false, - supports_streaming: true, - aliases: vec!["copilot-gpt4".into()], - }, // ══════════════════════════════════════════════════════════════ // Qwen / Alibaba (6) // ══════════════════════════════════════════════════════════════ From 6403871aa193515d951933b5a33da88117ce8b49 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 17:38:28 +1000 Subject: [PATCH 2/9] fix: strip trailing empty assistant messages for Claude/Gemini via Copilot proxy The Copilot API proxy rejects conversations ending with an empty assistant message as unsupported 'assistant message prefill' when proxying Claude and Gemini models. GPT models are unaffected. Strips trailing empty assistant messages (no content, no tool calls) before sending the request. Applied in both complete() and stream() paths. Also reverts unused fixup_request method from copilot.rs since the fix belongs in the OpenAI driver layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 8b927fa8..be28a094 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,6 +262,23 @@ struct OaiUsage { completion_tokens: u64, } +/// Strip trailing empty assistant messages from the message list. +/// Some API proxies (e.g. Copilot proxying Claude/Gemini) reject these as +/// unsupported "assistant message prefill". +fn strip_trailing_empty_assistant(messages: &mut Vec) { + while messages.last().map_or(false, |m| { + m.role == "assistant" + && m.tool_calls.is_none() + && match &m.content { + None => true, + Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), + _ => false, + } + }) { + messages.pop(); + } +} + #[async_trait] impl LlmDriver for OpenAIDriver { async fn complete(&self, request: CompletionRequest) -> Result { @@ -417,6 +434,8 @@ impl LlmDriver for OpenAIDriver { } } + strip_trailing_empty_assistant(&mut oai_messages); + let oai_tools: Vec = request .tools .iter() @@ -873,6 +892,8 @@ impl LlmDriver for OpenAIDriver { } } + strip_trailing_empty_assistant(&mut oai_messages); + let oai_tools: Vec = request .tools .iter() From 73d50c0284bdf11149dc81dc9c8bcabc935a4e46 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 18:55:43 +1000 Subject: [PATCH 3/9] debug: add OAI message logging for tool call investigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index be28a094..dfda1f94 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -463,6 +463,25 @@ impl LlmDriver for OpenAIDriver { } else { (Some(request.max_tokens), None) }; + + // Debug: log message structure for troubleshooting Copilot proxy issues + for (i, msg) in oai_messages.iter().enumerate() { + let tc_info = msg.tool_calls.as_ref().map(|tcs| { + tcs.iter() + .map(|tc| format!("{}:{}", tc.id, tc.function.name)) + .collect::>() + .join(", ") + }); + debug!( + idx = i, + role = %msg.role, + has_content = msg.content.is_some(), + tool_call_id = ?msg.tool_call_id, + tool_calls = ?tc_info, + "OAI message" + ); + } + let mut oai_request = OaiRequest { model: request.model.clone(), messages: oai_messages, From 3854e3eb898b77dcf462d3f685fee55bb8913f8c Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 19:01:23 +1000 Subject: [PATCH 4/9] chore: remove debug logging, keep defensive strip_trailing_empty_assistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index dfda1f94..294eccd5 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -464,24 +464,6 @@ impl LlmDriver for OpenAIDriver { (Some(request.max_tokens), None) }; - // Debug: log message structure for troubleshooting Copilot proxy issues - for (i, msg) in oai_messages.iter().enumerate() { - let tc_info = msg.tool_calls.as_ref().map(|tcs| { - tcs.iter() - .map(|tc| format!("{}:{}", tc.id, tc.function.name)) - .collect::>() - .join(", ") - }); - debug!( - idx = i, - role = %msg.role, - has_content = msg.content.is_some(), - tool_call_id = ?msg.tool_call_id, - tool_calls = ?tc_info, - "OAI message" - ); - } - let mut oai_request = OaiRequest { model: request.model.clone(), messages: oai_messages, From abeaaf544692a36946505029e470fab09b708e2f Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 19:13:43 +1000 Subject: [PATCH 5/9] fix: skip tool calls with empty ID or name from streaming response The Copilot API proxy can sometimes deliver streaming tool call chunks without a function name, resulting in empty-name tool calls stored in conversation history. When replayed to the API, these cause 'tool call must have a tool call ID and function name' errors. Skip malformed tool calls (empty ID or name) during streaming response finalization and log a warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 294eccd5..93580c1b 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -1339,6 +1339,16 @@ impl LlmDriver for OpenAIDriver { } for (id, name, arguments) in &tool_accum { + // Skip malformed tool calls (empty ID or name can happen if + // streaming chunks arrive out of order or are dropped by proxy). + if id.is_empty() || name.is_empty() { + warn!( + tool_id = %id, + tool_name = %name, + "Skipping tool call with empty ID or name from streaming response" + ); + continue; + } let input: serde_json::Value = serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({})); content.push(ContentBlock::ToolUse { From a8b4d3b48de4b9dc14826994cd5d62836ea41551 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 20:37:56 +1000 Subject: [PATCH 6/9] fix: strip all trailing assistant messages without tool_calls Strengthens the strip to remove any trailing assistant message (not just empty ones) when it has no tool_calls. The Copilot proxy for Claude rejects conversations ending with any assistant message as unsupported prefill. This fixes the Telegram bot channel where the agent loop appends an assistant message with content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 93580c1b..90648676 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,18 +262,13 @@ struct OaiUsage { completion_tokens: u64, } -/// Strip trailing empty assistant messages from the message list. -/// Some API proxies (e.g. Copilot proxying Claude/Gemini) reject these as -/// unsupported "assistant message prefill". +/// Strip trailing assistant messages without tool calls from the message list. +/// The Copilot API proxy for Claude rejects conversations ending with an +/// assistant message as unsupported "assistant message prefill". Assistant +/// messages with tool_calls are kept (they're part of the tool call protocol). fn strip_trailing_empty_assistant(messages: &mut Vec) { while messages.last().map_or(false, |m| { - m.role == "assistant" - && m.tool_calls.is_none() - && match &m.content { - None => true, - Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), - _ => false, - } + m.role == "assistant" && m.tool_calls.is_none() }) { messages.pop(); } From dc6119d2cca3ad39b62b65d45399642f890939a8 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 21:00:16 +1000 Subject: [PATCH 7/9] fix: revert aggressive assistant strip, keep empty-only version The aggressive strip (all trailing assistant messages) caused infinite agent loops by removing non-empty responses the agent loop needs. Reverted to only strip truly empty assistant messages (no content, no tool_calls). The Telegram prefill issue needs a different fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 90648676..f948d0dc 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,13 +262,20 @@ struct OaiUsage { completion_tokens: u64, } -/// Strip trailing assistant messages without tool calls from the message list. -/// The Copilot API proxy for Claude rejects conversations ending with an -/// assistant message as unsupported "assistant message prefill". Assistant -/// messages with tool_calls are kept (they're part of the tool call protocol). +/// Strip trailing empty assistant messages without tool calls. +/// Some API proxies (e.g. Copilot proxying Claude) reject conversations +/// ending with an empty assistant message as "assistant message prefill". +/// Only strips messages with no content and no tool_calls — non-empty +/// assistant messages are kept to avoid breaking the agent loop. fn strip_trailing_empty_assistant(messages: &mut Vec) { while messages.last().map_or(false, |m| { - m.role == "assistant" && m.tool_calls.is_none() + m.role == "assistant" + && m.tool_calls.is_none() + && match &m.content { + None => true, + Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), + _ => false, + } }) { messages.pop(); } From 64b1ec5a7ed834664ca3a24cbc80add9b6879d41 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 21:07:14 +1000 Subject: [PATCH 8/9] fix: model-aware assistant message stripping for Claude via Copilot The Copilot proxy for Claude enforces Anthropic's rule that conversations must end with a user message. For Claude models, strip any trailing assistant message without tool_calls (including non-empty ones). For other models, only strip truly empty assistant messages. This fixes the 'assistant message prefill not supported' error seen in Telegram and other channel adapters when using Claude via Copilot, without causing infinite agent loops for other models. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index f948d0dc..868f8af6 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,19 +262,27 @@ struct OaiUsage { completion_tokens: u64, } -/// Strip trailing empty assistant messages without tool calls. -/// Some API proxies (e.g. Copilot proxying Claude) reject conversations -/// ending with an empty assistant message as "assistant message prefill". -/// Only strips messages with no content and no tool_calls — non-empty -/// assistant messages are kept to avoid breaking the agent loop. -fn strip_trailing_empty_assistant(messages: &mut Vec) { +/// Strip trailing assistant messages that would trigger "prefill not supported" +/// errors on the Copilot proxy for Claude models. +/// Only strips assistant messages that have no tool_calls (tool call messages +/// are part of the protocol and must stay). Checks the model name to only +/// apply for Claude models which enforce this restriction. +fn strip_trailing_empty_assistant(messages: &mut Vec, model: &str) { + let is_claude = model.contains("claude"); + while messages.last().map_or(false, |m| { m.role == "assistant" && m.tool_calls.is_none() - && match &m.content { - None => true, - Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), - _ => false, + && if is_claude { + // Claude via Copilot: strip any trailing assistant without tool_calls + true + } else { + // Other models: only strip truly empty messages + match &m.content { + None => true, + Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), + _ => false, + } } }) { messages.pop(); @@ -436,7 +444,7 @@ impl LlmDriver for OpenAIDriver { } } - strip_trailing_empty_assistant(&mut oai_messages); + strip_trailing_empty_assistant(&mut oai_messages, &request.model); let oai_tools: Vec = request .tools @@ -895,7 +903,7 @@ impl LlmDriver for OpenAIDriver { } } - strip_trailing_empty_assistant(&mut oai_messages); + strip_trailing_empty_assistant(&mut oai_messages, &request.model); let oai_tools: Vec = request .tools From be1c4dba479bdb81ad136e0214a6d0760c07a4d1 Mon Sep 17 00:00:00 2001 From: Dmitry Butko Date: Thu, 9 Apr 2026 21:23:55 +1000 Subject: [PATCH 9/9] fix: revert Claude-specific strip, keep empty-only + empty tool name fix The model-aware assistant strip caused infinite agent loops for Claude. Reverted to empty-only strip which is safe for all models. The Telegram prefill issue needs to be fixed in the agent loop, not the driver. Remaining openai.rs changes: - strip_trailing_empty_assistant: strips truly empty trailing messages - Skip tool calls with empty ID or name from streaming responses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/openfang-runtime/src/drivers/openai.rs | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 868f8af6..e694aa4e 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -262,27 +262,16 @@ struct OaiUsage { completion_tokens: u64, } -/// Strip trailing assistant messages that would trigger "prefill not supported" -/// errors on the Copilot proxy for Claude models. -/// Only strips assistant messages that have no tool_calls (tool call messages -/// are part of the protocol and must stay). Checks the model name to only -/// apply for Claude models which enforce this restriction. -fn strip_trailing_empty_assistant(messages: &mut Vec, model: &str) { - let is_claude = model.contains("claude"); - +/// Strip trailing empty assistant messages without tool calls. +/// Some API proxies reject empty assistant messages as "prefill". +fn strip_trailing_empty_assistant(messages: &mut Vec) { while messages.last().map_or(false, |m| { m.role == "assistant" && m.tool_calls.is_none() - && if is_claude { - // Claude via Copilot: strip any trailing assistant without tool_calls - true - } else { - // Other models: only strip truly empty messages - match &m.content { - None => true, - Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), - _ => false, - } + && match &m.content { + None => true, + Some(OaiMessageContent::Text(t)) => t.trim().is_empty(), + _ => false, } }) { messages.pop(); @@ -444,7 +433,7 @@ impl LlmDriver for OpenAIDriver { } } - strip_trailing_empty_assistant(&mut oai_messages, &request.model); + strip_trailing_empty_assistant(&mut oai_messages); let oai_tools: Vec = request .tools @@ -903,7 +892,7 @@ impl LlmDriver for OpenAIDriver { } } - strip_trailing_empty_assistant(&mut oai_messages, &request.model); + strip_trailing_empty_assistant(&mut oai_messages); let oai_tools: Vec = request .tools