diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 8cab46c2d..ee0b0f14b 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -931,6 +931,7 @@ export interface ProviderStatus { minimax_cn: boolean; moonshot: boolean; zai_coding_plan: boolean; + github_copilot: boolean; } export interface ProvidersResponse { diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index 8a2b3c7df..c44b5173c 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -31,6 +31,7 @@ const PROVIDER_LABELS: Record = { "opencode-go": "OpenCode Go", minimax: "MiniMax", "minimax-cn": "MiniMax CN", + "github-copilot": "GitHub Copilot", }; function formatContextWindow(tokens: number | null): string { @@ -134,6 +135,7 @@ export function ModelSelect({ "anthropic", "openai", "openai-chatgpt", + "github-copilot", "ollama", "deepseek", "xai", diff --git a/interface/src/lib/providerIcons.tsx b/interface/src/lib/providerIcons.tsx index eaf286c79..f0207ede2 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -12,6 +12,7 @@ import ZAI from "@lobehub/icons/es/ZAI"; import Minimax from "@lobehub/icons/es/Minimax"; import Kimi from "@lobehub/icons/es/Kimi"; import Google from "@lobehub/icons/es/Google"; +import GithubCopilot from "@lobehub/icons/es/GithubCopilot"; interface IconProps { size?: number; @@ -138,6 +139,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 minimax: Minimax, "minimax-cn": Minimax, moonshot: Kimi, // Kimi is Moonshot AI's product brand + "github-copilot": GithubCopilot, }; const IconComponent = iconMap[provider.toLowerCase()]; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 9ea2df47d..90aa6e96d 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -242,6 +242,14 @@ const PROVIDERS = [ envVar: "MOONSHOT_API_KEY", defaultModel: "moonshot/kimi-k2.5", }, + { + id: "github-copilot", + name: "GitHub Copilot", + description: "GitHub Copilot API (uses GitHub PAT for token exchange)", + placeholder: "ghp_... or gh auth token", + envVar: "GITHUB_COPILOT_API_KEY", + defaultModel: "github-copilot/claude-sonnet-4", + }, { id: "ollama", name: "Ollama", diff --git a/src/api/providers.rs b/src/api/providers.rs index 39c9d6558..5722d76d3 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -60,6 +60,7 @@ pub(super) struct ProviderStatus { minimax_cn: bool, moonshot: bool, zai_coding_plan: bool, + github_copilot: bool, } #[derive(Serialize)] @@ -146,6 +147,7 @@ fn provider_toml_key(provider: &str) -> Option<&'static str> { "minimax-cn" => Some("minimax_cn_key"), "moonshot" => Some("moonshot_key"), "zai-coding-plan" => Some("zai_coding_plan_key"), + "github-copilot" => Some("github_copilot_key"), _ => None, } } @@ -214,6 +216,7 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm minimax_cn_key: (provider == "minimax-cn").then(|| credential.to_string()), moonshot_key: (provider == "moonshot").then(|| credential.to_string()), zai_coding_plan_key: (provider == "zai-coding-plan").then(|| credential.to_string()), + github_copilot_key: (provider == "github-copilot").then(|| credential.to_string()), providers, } } @@ -369,6 +372,7 @@ pub(super) async fn get_providers( minimax_cn, moonshot, zai_coding_plan, + github_copilot, ) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await @@ -413,6 +417,7 @@ pub(super) async fn get_providers( has_value("minimax_cn_key", "MINIMAX_CN_API_KEY"), has_value("moonshot_key", "MOONSHOT_API_KEY"), has_value("zai_coding_plan_key", "ZAI_CODING_PLAN_API_KEY"), + has_value("github_copilot_key", "GITHUB_COPILOT_API_KEY"), ) } else { ( @@ -437,6 +442,7 @@ pub(super) async fn get_providers( std::env::var("MINIMAX_CN_API_KEY").is_ok(), std::env::var("MOONSHOT_API_KEY").is_ok(), std::env::var("ZAI_CODING_PLAN_API_KEY").is_ok(), + std::env::var("GITHUB_COPILOT_API_KEY").is_ok(), ) }; @@ -462,6 +468,7 @@ pub(super) async fn get_providers( minimax_cn, moonshot, zai_coding_plan, + github_copilot, }; let has_any = providers.anthropic || providers.openai @@ -483,7 +490,8 @@ pub(super) async fn get_providers( || providers.minimax || providers.minimax_cn || providers.moonshot - || providers.zai_coding_plan; + || providers.zai_coding_plan + || providers.github_copilot; Ok(Json(ProvidersResponse { providers, has_any })) } @@ -914,6 +922,21 @@ pub(super) async fn delete_provider( })); } + // GitHub Copilot has a cached token file alongside the TOML key. + // Remove both the TOML key and the cached token. + if provider == "github-copilot" { + let instance_dir = (**state.instance_dir.load()).clone(); + let token_path = crate::github_copilot_auth::credentials_path(&instance_dir); + if token_path.exists() { + tokio::fs::remove_file(&token_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + if let Some(manager) = state.llm_manager.read().await.as_ref() { + manager.clear_copilot_token().await; + } + } + let Some(key_name) = provider_toml_key(&provider) else { return Ok(Json(ProviderUpdateResponse { success: false, diff --git a/src/config/load.rs b/src/config/load.rs index eec7d3d0b..a4b44882c 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1,12 +1,13 @@ use super::providers::{ ANTHROPIC_PROVIDER_BASE_URL, DEEPSEEK_PROVIDER_BASE_URL, FIREWORKS_PROVIDER_BASE_URL, - GEMINI_PROVIDER_BASE_URL, GROQ_PROVIDER_BASE_URL, KILO_PROVIDER_BASE_URL, - MINIMAX_CN_PROVIDER_BASE_URL, MINIMAX_PROVIDER_BASE_URL, MISTRAL_PROVIDER_BASE_URL, - MOONSHOT_PROVIDER_BASE_URL, NVIDIA_PROVIDER_BASE_URL, OLLAMA_PROVIDER_BASE_URL, - OPENAI_PROVIDER_BASE_URL, OPENCODE_GO_PROVIDER_BASE_URL, OPENCODE_ZEN_PROVIDER_BASE_URL, - OPENROUTER_PROVIDER_BASE_URL, TOGETHER_PROVIDER_BASE_URL, XAI_PROVIDER_BASE_URL, - ZAI_CODING_PLAN_BASE_URL, ZHIPU_PROVIDER_BASE_URL, add_shorthand_provider, - infer_routing_from_providers, openrouter_extra_headers, resolve_routing, + GEMINI_PROVIDER_BASE_URL, GITHUB_COPILOT_DEFAULT_BASE_URL, GROQ_PROVIDER_BASE_URL, + KILO_PROVIDER_BASE_URL, MINIMAX_CN_PROVIDER_BASE_URL, MINIMAX_PROVIDER_BASE_URL, + MISTRAL_PROVIDER_BASE_URL, MOONSHOT_PROVIDER_BASE_URL, NVIDIA_PROVIDER_BASE_URL, + OLLAMA_PROVIDER_BASE_URL, OPENAI_PROVIDER_BASE_URL, OPENCODE_GO_PROVIDER_BASE_URL, + OPENCODE_ZEN_PROVIDER_BASE_URL, OPENROUTER_PROVIDER_BASE_URL, TOGETHER_PROVIDER_BASE_URL, + XAI_PROVIDER_BASE_URL, ZAI_CODING_PLAN_BASE_URL, ZHIPU_PROVIDER_BASE_URL, + add_shorthand_provider, infer_routing_from_providers, openrouter_extra_headers, + resolve_routing, }; use super::toml_schema::*; use super::{ @@ -430,6 +431,7 @@ impl Config { minimax_cn_key: std::env::var("MINIMAX_CN_API_KEY").ok(), moonshot_key: std::env::var("MOONSHOT_API_KEY").ok(), zai_coding_plan_key: std::env::var("ZAI_CODING_PLAN_API_KEY").ok(), + github_copilot_key: std::env::var("GITHUB_COPILOT_API_KEY").ok(), providers: HashMap::new(), }; @@ -510,6 +512,16 @@ impl Config { false, ); + add_shorthand_provider( + &mut llm.providers, + "github-copilot", + llm.github_copilot_key.clone(), + ApiType::OpenAiChatCompletions, + GITHUB_COPILOT_DEFAULT_BASE_URL, + Some("GitHub Copilot"), + true, + ); + if let Some(minimax_key) = llm.minimax_key.clone() { llm.providers .entry("minimax".to_string()) @@ -1060,6 +1072,12 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("ZAI_CODING_PLAN_API_KEY").ok()), + github_copilot_key: toml + .llm + .github_copilot_key + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("GITHUB_COPILOT_API_KEY").ok()), providers: toml .llm .providers @@ -1186,6 +1204,16 @@ impl Config { false, ); + add_shorthand_provider( + &mut llm.providers, + "github-copilot", + llm.github_copilot_key.clone(), + ApiType::OpenAiChatCompletions, + GITHUB_COPILOT_DEFAULT_BASE_URL, + Some("GitHub Copilot"), + true, + ); + if let Some(minimax_key) = llm.minimax_key.clone() { llm.providers .entry("minimax".to_string()) diff --git a/src/config/providers.rs b/src/config/providers.rs index 096b58606..091ca71cc 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -26,6 +26,7 @@ pub(super) const NVIDIA_PROVIDER_BASE_URL: &str = "https://integrate.api.nvidia. pub(super) const FIREWORKS_PROVIDER_BASE_URL: &str = "https://api.fireworks.ai/inference"; pub(crate) const GEMINI_PROVIDER_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai"; +pub(super) const GITHUB_COPILOT_DEFAULT_BASE_URL: &str = "https://api.individual.githubcopilot.com"; /// App attribution headers sent with every OpenRouter API request. /// See . @@ -213,6 +214,9 @@ pub(crate) fn default_provider_config( use_bearer_auth: false, extra_headers: vec![], }, + // GitHub Copilot requires token exchange and dynamic base URL derivation. + // The test path should use LlmManager::get_github_copilot_provider() instead. + "github-copilot" => return None, _ => return None, }) } @@ -274,6 +278,7 @@ pub(super) fn infer_routing_from_providers( "minimax-cn", "moonshot", "zai-coding-plan", + "github-copilot", ]; for &name in PRIORITY { diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index d4453f2e0..a064624f8 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -176,6 +176,7 @@ pub(super) struct TomlLlmConfigFields { pub(super) minimax_cn_key: Option, pub(super) moonshot_key: Option, pub(super) zai_coding_plan_key: Option, + pub(super) github_copilot_key: Option, #[serde(default)] pub(super) providers: HashMap, #[serde(default)] @@ -206,6 +207,7 @@ pub(super) struct TomlLlmConfig { pub(super) minimax_cn_key: Option, pub(super) moonshot_key: Option, pub(super) zai_coding_plan_key: Option, + pub(super) github_copilot_key: Option, pub(super) providers: HashMap, } @@ -261,6 +263,7 @@ impl<'de> Deserialize<'de> for TomlLlmConfig { minimax_cn_key: fields.minimax_cn_key, moonshot_key: fields.moonshot_key, zai_coding_plan_key: fields.zai_coding_plan_key, + github_copilot_key: fields.github_copilot_key, providers: fields.providers, }) } diff --git a/src/config/types.rs b/src/config/types.rs index 93b66982e..fe9d31463 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -269,6 +269,7 @@ pub struct LlmConfig { pub minimax_cn_key: Option, pub moonshot_key: Option, pub zai_coding_plan_key: Option, + pub github_copilot_key: Option, pub providers: HashMap, } @@ -340,6 +341,10 @@ impl std::fmt::Debug for LlmConfig { "zai_coding_plan_key", &self.zai_coding_plan_key.as_ref().map(|_| "[REDACTED]"), ) + .field( + "github_copilot_key", + &self.github_copilot_key.as_ref().map(|_| "[REDACTED]"), + ) .field("providers", &self.providers) .finish() } @@ -369,6 +374,7 @@ impl LlmConfig { || self.minimax_cn_key.is_some() || self.moonshot_key.is_some() || self.zai_coding_plan_key.is_some() + || self.github_copilot_key.is_some() || !self.providers.is_empty() } } @@ -500,6 +506,11 @@ impl SystemSecrets for LlmConfig { secret_name: "SAMBANOVA_API_KEY", instance_pattern: None, }, + SecretField { + toml_key: "github_copilot_key", + secret_name: "GITHUB_COPILOT_API_KEY", + instance_pattern: None, + }, ] } } diff --git a/src/github_copilot_auth.rs b/src/github_copilot_auth.rs new file mode 100644 index 000000000..ffb937625 --- /dev/null +++ b/src/github_copilot_auth.rs @@ -0,0 +1,314 @@ +//! GitHub Copilot token exchange, caching, and base URL derivation. +//! +//! The user provides a GitHub PAT (e.g. from `gh auth token`). This module +//! exchanges it for a short-lived Copilot API token via the internal GitHub +//! endpoint, caches the result on disk, and derives the provider base URL +//! from the token's embedded `proxy-ep` field. + +use anyhow::{Context as _, Result}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; + +/// Default base URL when `proxy-ep` cannot be extracted from the token. +pub const DEFAULT_COPILOT_API_BASE_URL: &str = "https://api.individual.githubcopilot.com"; + +/// Safety margin (milliseconds) subtracted from the expiry time when checking +/// whether a cached token is still usable. Matches OpenCode's 5-minute buffer. +const EXPIRY_SAFETY_MARGIN_MS: i64 = 5 * 60 * 1000; + +static PROXY_EP_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?:^|;)\s*proxy-ep=([^;\s]+)").unwrap()); + +/// Cached Copilot API token stored on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopilotToken { + pub token: String, + /// SHA-256 hex digest of the GitHub PAT used to obtain this token. + pub pat_hash: String, + /// Expiry as Unix timestamp in milliseconds. + pub expires_at_ms: i64, + /// When this cache entry was last updated (Unix milliseconds). + pub updated_at_ms: i64, +} + +impl CopilotToken { + /// Check if the token is expired or about to expire (within 5 minutes). + pub fn is_expired(&self) -> bool { + let now = chrono::Utc::now().timestamp_millis(); + now >= self.expires_at_ms - EXPIRY_SAFETY_MARGIN_MS + } +} + +/// Response from the GitHub Copilot token endpoint. +#[derive(Debug, Deserialize)] +struct TokenExchangeResponse { + token: String, + expires_at: serde_json::Value, +} + +/// Compute the SHA-256 hex digest of a GitHub PAT. +pub fn hash_pat(pat: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(pat.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Exchange a GitHub PAT for a Copilot API token. +pub async fn exchange_github_token( + client: &reqwest::Client, + github_pat: &str, + pat_hash: String, +) -> Result { + let response = client + .get(COPILOT_TOKEN_URL) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {github_pat}")) + .header( + "User-Agent", + format!("spacebot/{}", env!("CARGO_PKG_VERSION")), + ) + .send() + .await + .context("failed to send Copilot token exchange request")?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read Copilot token exchange response")?; + + if !status.is_success() { + let hint = if status == reqwest::StatusCode::NOT_FOUND { + " (hint: gh auth tokens may not have Copilot access)" + } else { + "" + }; + anyhow::bail!( + "Copilot token exchange failed ({}): {}{}", + status, + body, + hint, + ); + } + + let exchange: TokenExchangeResponse = + serde_json::from_str(&body).context("failed to parse Copilot token exchange response")?; + + let expires_at_ms = parse_expires_at(&exchange.expires_at) + .context("Copilot token response has invalid expires_at")?; + + Ok(CopilotToken { + token: exchange.token, + pat_hash, + expires_at_ms, + updated_at_ms: chrono::Utc::now().timestamp_millis(), + }) +} + +/// Parse `expires_at` from the token response. GitHub returns a Unix timestamp +/// in seconds, but we defensively accept milliseconds too (heuristic: values +/// greater than 10 billion are treated as milliseconds). +fn parse_expires_at(value: &serde_json::Value) -> Option { + let raw = match value { + serde_json::Value::Number(number) => number.as_i64()?, + serde_json::Value::String(string) => string.trim().parse::().ok()?, + _ => return None, + }; + + // Heuristic: 10^10 seconds ≈ year 2286, so values above this are likely milliseconds. + // This safely handles both formats for any reasonable token expiry. + if raw > 10_000_000_000 { + Some(raw) // already milliseconds + } else { + Some(raw * 1000) // convert seconds to milliseconds + } +} + +/// Derive the Copilot API base URL from the token's embedded `proxy-ep` field. +/// +/// The token is a semicolon-delimited set of key=value pairs. One of them is +/// `proxy-ep=proxy.individual.githubcopilot.com`. We extract the hostname, +/// replace `proxy.` with `api.`, and prefix `https://`. +/// +/// Security: validates the hostname ends with `.githubcopilot.com` to prevent +/// a tampered cache file from redirecting requests to an arbitrary host. +pub fn derive_base_url_from_token(token: &str) -> Option { + let captures = PROXY_EP_RE.captures(token.trim())?; + let proxy_ep = captures.get(1)?.as_str().trim(); + if proxy_ep.is_empty() { + return None; + } + + // Strip any protocol prefix + let host = proxy_ep + .trim_start_matches("https://") + .trim_start_matches("http://"); + + // Extract just the hostname (no path, no port) + let hostname = host.split('/').next()?.split(':').next()?; + if hostname.is_empty() { + return None; + } + + // Security: validate expected suffix to prevent token exfiltration + if !hostname.ends_with(".githubcopilot.com") { + tracing::warn!(%hostname, "proxy-ep hostname has unexpected suffix; rejecting"); + return None; + } + + // Replace proxy. → api. + let api_host = if let Some(rest) = hostname.strip_prefix("proxy.") { + format!("api.{rest}") + } else { + format!("api.{hostname}") + }; + + Some(format!("https://{api_host}")) +} + +/// Path to the cached Copilot token within the instance directory. +pub fn credentials_path(instance_dir: &Path) -> PathBuf { + instance_dir.join("github_copilot_token.json") +} + +/// Load a cached Copilot token from disk. +pub fn load_cached_token(instance_dir: &Path) -> Result> { + let path = credentials_path(instance_dir); + if !path.exists() { + return Ok(None); + } + + let data = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let token: CopilotToken = + serde_json::from_str(&data).context("failed to parse cached Copilot token")?; + Ok(Some(token)) +} + +/// Save a Copilot token to disk with restricted permissions (0600). +/// +/// On Unix, creates the file atomically with mode 0o600 to avoid a brief +/// window where the file is readable by others. On non-Unix platforms, +/// falls back to a best-effort write. +pub fn save_cached_token(instance_dir: &Path, token: &CopilotToken) -> Result<()> { + let path = credentials_path(instance_dir); + let data = serde_json::to_string_pretty(token).context("failed to serialize Copilot token")?; + + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&path) + .with_context(|| { + format!( + "failed to create {} with restricted permissions", + path.display() + ) + })?; + use std::io::Write; + file.write_all(data.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", path.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::write(&path, &data) + .with_context(|| format!("failed to write {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_base_url_from_proxy_ep() { + let token = + "tid=abc123;exp=1234567890;proxy-ep=proxy.individual.githubcopilot.com;st=dotcom"; + assert_eq!( + derive_base_url_from_token(token), + Some("https://api.individual.githubcopilot.com".to_string()) + ); + } + + #[test] + fn derive_base_url_no_proxy_ep() { + let token = "tid=abc123;exp=1234567890;st=dotcom"; + assert_eq!(derive_base_url_from_token(token), None); + } + + #[test] + fn derive_base_url_empty_token() { + assert_eq!(derive_base_url_from_token(""), None); + } + + #[test] + fn derive_base_url_rejects_invalid_suffix() { + // Security test: reject proxy endpoints not ending with .githubcopilot.com + let token = "tid=abc123;exp=1234567890;proxy-ep=proxy.evil.com;st=dotcom"; + assert_eq!(derive_base_url_from_token(token), None); + } + + #[test] + fn parse_expires_at_seconds() { + let value = serde_json::json!(1700000000); + assert_eq!(parse_expires_at(&value), Some(1700000000000)); + } + + #[test] + fn parse_expires_at_milliseconds() { + let value = serde_json::json!(1700000000000_i64); + assert_eq!(parse_expires_at(&value), Some(1700000000000)); + } + + #[test] + fn parse_expires_at_string() { + let value = serde_json::json!("1700000000"); + assert_eq!(parse_expires_at(&value), Some(1700000000000)); + } + + #[test] + fn hash_pat_deterministic() { + let hash1 = hash_pat("ghu_abc123"); + let hash2 = hash_pat("ghu_abc123"); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex = 64 chars + // Different PAT produces different hash + assert_ne!(hash1, hash_pat("ghu_different")); + } + + #[test] + fn token_expired_check() { + let expired = CopilotToken { + token: "test".to_string(), + pat_hash: hash_pat("test_pat"), + expires_at_ms: 0, + updated_at_ms: 0, + }; + assert!(expired.is_expired()); + + let future = CopilotToken { + token: "test".to_string(), + pat_hash: hash_pat("test_pat"), + expires_at_ms: chrono::Utc::now().timestamp_millis() + 3_600_000, + updated_at_ms: 0, + }; + assert!(!future.is_expired()); + } +} diff --git a/src/lib.rs b/src/lib.rs index fba359745..2c17a2b6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod daemon; pub mod db; pub mod error; pub mod factory; +pub mod github_copilot_auth; pub mod hooks; pub mod identity; pub mod links; diff --git a/src/llm/manager.rs b/src/llm/manager.rs index bd0044d49..d3fe79cb8 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -11,12 +11,21 @@ use crate::auth::OAuthCredentials as AnthropicOAuthCredentials; use crate::config::{ApiType, LlmConfig, ProviderConfig}; use crate::error::{LlmError, Result}; +use crate::github_copilot_auth::CopilotToken; use crate::openai_auth::OAuthCredentials as OpenAiOAuthCredentials; use anyhow::Context as _; use arc_swap::ArcSwap; use std::collections::HashMap; use std::path::PathBuf; + +/// Editor version header for GitHub Copilot API requests. +/// Matches VSCode 1.96.2 which Copilot expects for IDE auth. +const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.2"; + +/// Editor plugin version header for GitHub Copilot API requests. +/// Matches Copilot Chat extension version 0.26.7. +const COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.26.7"; use std::sync::Arc; use std::time::Instant; use tokio::sync::RwLock; @@ -33,6 +42,8 @@ pub struct LlmManager { anthropic_oauth_credentials: RwLock>, /// Cached OpenAI OAuth credentials (refreshed lazily). openai_oauth_credentials: RwLock>, + /// Cached GitHub Copilot API token (exchanged from PAT, refreshed lazily). + copilot_token: RwLock>, } impl LlmManager { @@ -50,6 +61,7 @@ impl LlmManager { instance_dir: None, anthropic_oauth_credentials: RwLock::new(None), openai_oauth_credentials: RwLock::new(None), + copilot_token: RwLock::new(None), }) } @@ -63,6 +75,18 @@ impl LlmManager { tracing::info!("loaded OpenAI OAuth credentials from openai_chatgpt_oauth.json"); *self.openai_oauth_credentials.write().await = Some(creds); } + match crate::github_copilot_auth::load_cached_token(&instance_dir) { + Ok(Some(token)) => { + tracing::info!("loaded GitHub Copilot token from github_copilot_token.json"); + *self.copilot_token.write().await = Some(token); + } + Ok(None) => { + tracing::debug!("no cached GitHub Copilot token found"); + } + Err(error) => { + tracing::warn!(%error, "failed to load GitHub Copilot token"); + } + } // Store instance_dir — we can't set it on &self since it's not behind RwLock, // but we only need it for save_credentials which we handle inline. } @@ -98,6 +122,18 @@ impl LlmManager { } }; + let copilot_token = match crate::github_copilot_auth::load_cached_token(&instance_dir) { + Ok(Some(token)) => { + tracing::info!("loaded GitHub Copilot token from github_copilot_token.json"); + Some(token) + } + Ok(None) => None, + Err(error) => { + tracing::warn!(%error, "failed to load GitHub Copilot token"); + None + } + }; + Ok(Self { config: ArcSwap::from_pointee(config), http_client, @@ -105,6 +141,7 @@ impl LlmManager { instance_dir: Some(instance_dir), anthropic_oauth_credentials: RwLock::new(anthropic_oauth_credentials), openai_oauth_credentials: RwLock::new(openai_oauth_credentials), + copilot_token: RwLock::new(copilot_token), }) } @@ -271,6 +308,116 @@ impl LlmManager { .and_then(|credentials| credentials.account_id.clone()) } + /// Get a valid GitHub Copilot API token, exchanging/refreshing as needed. + /// + /// Reads the GitHub PAT from the `github-copilot` provider config, checks + /// whether the cached Copilot token is still valid, and exchanges for a new + /// one if expired or missing. Saves refreshed tokens to disk. + pub async fn get_copilot_token(&self) -> Result> { + // Check if there's a github-copilot provider configured with a PAT + let github_pat = match self.get_provider("github-copilot") { + Ok(provider) if !provider.api_key.is_empty() => provider.api_key, + _ => return Ok(None), + }; + + let pat_hash = crate::github_copilot_auth::hash_pat(&github_pat); + + // Check cached token — must be unexpired AND for the same PAT + { + let token_guard = self.copilot_token.read().await; + if let Some(ref cached) = *token_guard + && !cached.is_expired() + && cached.pat_hash == pat_hash + { + return Ok(Some(cached.token.clone())); + } + } // read lock dropped here before network call + + // Need to exchange + tracing::info!("exchanging GitHub PAT for Copilot API token..."); + match crate::github_copilot_auth::exchange_github_token( + &self.http_client, + &github_pat, + pat_hash.clone(), + ) + .await + { + Ok(new_token) => { + let api_token = new_token.token.clone(); + // Save to disk + if let Some(ref instance_dir) = self.instance_dir + && let Err(error) = + crate::github_copilot_auth::save_cached_token(instance_dir, &new_token) + { + tracing::warn!(%error, "failed to persist GitHub Copilot token"); + } + // Update cache with write lock held only for the assignment + *self.copilot_token.write().await = Some(new_token); + tracing::info!("GitHub Copilot token exchanged successfully"); + Ok(Some(api_token)) + } + Err(error) => { + tracing::error!(%error, "GitHub Copilot token exchange failed"); + // Only fall back to cached token if it matches the current PAT hash + let token_guard = self.copilot_token.read().await; + if let Some(ref cached) = *token_guard + && cached.pat_hash == pat_hash + { + return Ok(Some(cached.token.clone())); + } + Err(error.into()) + } + } + } + + /// Resolve the GitHub Copilot provider config with a fresh API token. + /// + /// Exchanges the stored GitHub PAT for a Copilot API token, derives the + /// base URL from the token's `proxy-ep` field, and returns a complete + /// `ProviderConfig` ready for OpenAI-compatible API calls. + pub async fn get_github_copilot_provider(&self) -> Result { + let token = self + .get_copilot_token() + .await? + .ok_or_else(|| LlmError::UnknownProvider("github-copilot".to_string()))?; + + let base_url = crate::github_copilot_auth::derive_base_url_from_token(&token) + .unwrap_or_else(|| { + crate::github_copilot_auth::DEFAULT_COPILOT_API_BASE_URL.to_string() + }); + + Ok(ProviderConfig { + api_type: ApiType::OpenAiChatCompletions, + base_url, + api_key: token, + name: Some("GitHub Copilot".to_string()), + use_bearer_auth: true, + extra_headers: vec![ + ( + "user-agent".to_string(), + format!("spacebot/{}", env!("CARGO_PKG_VERSION")), + ), + ( + "editor-version".to_string(), + COPILOT_EDITOR_VERSION.to_string(), + ), + ( + "editor-plugin-version".to_string(), + COPILOT_EDITOR_PLUGIN_VERSION.to_string(), + ), + ], + }) + } + + /// Clear cached GitHub Copilot token from memory only. + /// + /// Note: Does not delete the on-disk cache file. Use + /// `github_copilot_auth::credentials_path()` and delete the file separately + /// if persistent removal is needed (e.g., in `delete_provider`). + pub async fn clear_copilot_token(&self) { + *self.copilot_token.write().await = None; + } + /// Get the appropriate API key for a provider. pub fn get_api_key(&self, provider_id: &str) -> Result { let provider = self.get_provider(provider_id)?; diff --git a/src/llm/model.rs b/src/llm/model.rs index 9dadcd93f..c24c16459 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -112,6 +112,11 @@ impl SpacebotModel { .get_openai_chatgpt_provider() .await .map_err(|error| CompletionError::ProviderError(error.to_string())), + "github-copilot" => self + .llm_manager + .get_github_copilot_provider() + .await + .map_err(|error| CompletionError::ProviderError(error.to_string())), _ => self .llm_manager .get_provider(provider_id) diff --git a/src/llm/routing.rs b/src/llm/routing.rs index d6a435dec..eb677a8c8 100644 --- a/src/llm/routing.rs +++ b/src/llm/routing.rs @@ -396,6 +396,22 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { "minimax-cn" => RoutingConfig::for_model("minimax-cn/MiniMax-M2.5".into()), "moonshot" => RoutingConfig::for_model("moonshot/kimi-k2.5".into()), "zai-coding-plan" => RoutingConfig::for_model("zai-coding-plan/glm-5".into()), + "github-copilot" => { + let channel: String = "github-copilot/claude-sonnet-4".into(); + let worker: String = "github-copilot/gpt-4.1-mini".into(); + RoutingConfig { + channel: channel.clone(), + branch: channel.clone(), + worker: worker.clone(), + compactor: worker.clone(), + cortex: worker.clone(), + voice: String::new(), + task_overrides: HashMap::from([("coding".into(), channel.clone())]), + fallbacks: HashMap::from([(channel, vec![worker])]), + rate_limit_cooldown_secs: 60, + ..RoutingConfig::default() + } + } // Unknown — use the standard defaults _ => RoutingConfig::default(), } @@ -424,6 +440,7 @@ pub fn provider_to_prefix(provider: &str) -> &str { "minimax-cn" => "minimax-cn/", "moonshot" => "moonshot/", "zai-coding-plan" => "zai-coding-plan/", + "github-copilot" => "github-copilot/", _ => "", } } diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index abf72e239..bb0681fab 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -150,14 +150,13 @@ impl Tool for SendMessageTool { // If explicit prefix returned default "signal" adapter but we're in a named // Signal adapter conversation (e.g., signal:gvoice1), use the current adapter // to ensure the message goes through the correct account. - if target.adapter == "signal" { - if let Some(current_adapter) = self + if target.adapter == "signal" + && let Some(current_adapter) = self .current_adapter .as_ref() .filter(|adapter| adapter.starts_with("signal:")) - { - target.adapter = current_adapter.clone(); - } + { + target.adapter = current_adapter.clone(); } self.messaging_manager @@ -189,31 +188,28 @@ impl Tool for SendMessageTool { .current_adapter .as_ref() .filter(|adapter| adapter.starts_with("signal")) + && let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter) { - if let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter) { - self.messaging_manager - .broadcast( - &target.adapter, - &target.target, - crate::OutboundResponse::Text(args.message), - ) - .await - .map_err(|error| { - SendMessageError(format!("failed to send message: {error}")) - })?; - - tracing::info!( - adapter = %target.adapter, - broadcast_target = %"[REDACTED]", - "message sent via implicit Signal shorthand" - ); - - return Ok(SendMessageOutput { - success: true, - target: target.target, - platform: target.adapter, - }); - } + self.messaging_manager + .broadcast( + &target.adapter, + &target.target, + crate::OutboundResponse::Text(args.message), + ) + .await + .map_err(|error| SendMessageError(format!("failed to send message: {error}")))?; + + tracing::info!( + adapter = %target.adapter, + broadcast_target = %"[REDACTED]", + "message sent via implicit Signal shorthand" + ); + + return Ok(SendMessageOutput { + success: true, + target: target.target, + platform: target.adapter, + }); } // Check for explicit email target