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) // ══════════════════════════════════════════════════════════════