mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
* feat: Add WeCom (WeChat Work) channel adapter - Add wecom.rs channel adapter implementation - Add WeComConfig in config.rs - Register WeCom adapter in channel_bridge.rs WeCom channel supports: - Inbound messages via callback webhook - Outbound messages via WeCom API - Access token caching and auto-refresh Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle WeCom callbacks and preserve hand extension tools * fix: render WeCom replies as plain text * fix: resolve clippy warnings in wecom adapter - Remove unused WECOM_API_HOST constant - Fix needless borrow in send_text call - Replace assert_eq!(bool, true) with assert!() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt for wecom-related files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt --all Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: upgrade quinn-proto and add cargo audit ignore list - Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix) - Add .cargo/audit.toml to ignore unmaintainable transitive deps (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
//! GitHub Copilot authentication — exchanges a GitHub PAT for a Copilot API token.
|
|
//!
|
|
//! The Copilot API uses the OpenAI chat completions format, so this module
|
|
//! handles token exchange and caching, then delegates to the OpenAI-compatible driver.
|
|
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
use tracing::{debug, warn};
|
|
use zeroize::Zeroizing;
|
|
|
|
/// 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);
|
|
|
|
/// Refresh buffer — refresh token this many seconds before expiry.
|
|
const REFRESH_BUFFER_SECS: u64 = 300; // 5 minutes
|
|
|
|
/// Default Copilot API base URL.
|
|
pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
|
|
|
/// Cached Copilot API token with expiry and derived base URL.
|
|
#[derive(Clone)]
|
|
pub struct CachedToken {
|
|
/// The Copilot API token (zeroized on drop).
|
|
pub token: Zeroizing<String>,
|
|
/// When this token expires.
|
|
pub expires_at: Instant,
|
|
/// Base URL derived from proxy-ep in the token (or default).
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Thread-safe token cache for a single Copilot session.
|
|
pub struct CopilotTokenCache {
|
|
cached: Mutex<Option<CachedToken>>,
|
|
}
|
|
|
|
impl CopilotTokenCache {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
cached: Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
/// Get a valid cached token, or None if expired/missing.
|
|
pub fn get(&self) -> Option<CachedToken> {
|
|
let lock = self.cached.lock().unwrap_or_else(|e| e.into_inner());
|
|
lock.as_ref().filter(|t| t.is_valid()).cloned()
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
impl Default for CopilotTokenCache {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// 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<CachedToken, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(TOKEN_EXCHANGE_TIMEOUT)
|
|
.build()
|
|
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
|
|
|
debug!("Exchanging GitHub token for Copilot API token");
|
|
|
|
let resp = client
|
|
.get(COPILOT_TOKEN_URL)
|
|
.header("Authorization", format!("token {github_token}"))
|
|
.header("Accept", "application/json")
|
|
.header("User-Agent", "OpenFang/1.0")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Copilot token exchange failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("Copilot token exchange returned {status}: {body}"));
|
|
}
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse Copilot token response: {e}"))?;
|
|
|
|
let raw_token = body
|
|
.get("token")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("Missing 'token' field in Copilot response")?;
|
|
|
|
let expires_at_unix = body.get("expires_at").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
|
|
// Calculate Duration from now until expiry
|
|
let now_unix = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64;
|
|
let ttl_secs = (expires_at_unix - now_unix).max(60) as u64;
|
|
|
|
let (_, proxy_ep) = parse_copilot_token(raw_token);
|
|
let base_url = proxy_ep.unwrap_or_else(|| GITHUB_COPILOT_BASE_URL.to_string());
|
|
|
|
// SECURITY: Validate HTTPS on the base URL
|
|
if !base_url.starts_with("https://") {
|
|
warn!(url = %base_url, "Copilot proxy-ep is not HTTPS, using default");
|
|
return Ok(CachedToken {
|
|
token: Zeroizing::new(raw_token.to_string()),
|
|
expires_at: Instant::now() + Duration::from_secs(ttl_secs),
|
|
base_url: GITHUB_COPILOT_BASE_URL.to_string(),
|
|
});
|
|
}
|
|
|
|
Ok(CachedToken {
|
|
token: Zeroizing::new(raw_token.to_string()),
|
|
expires_at: Instant::now() + Duration::from_secs(ttl_secs),
|
|
base_url,
|
|
})
|
|
}
|
|
|
|
/// Parse the semicolon-delimited Copilot token to extract proxy endpoint.
|
|
///
|
|
/// Token format: `tid=...;exp=...;sku=...;proxy-ep=https://...;...`
|
|
/// Returns (cleaned_token, Option<proxy_ep_url>).
|
|
pub fn parse_copilot_token(raw: &str) -> (String, Option<String>) {
|
|
let mut proxy_ep = None;
|
|
|
|
for segment in raw.split(';') {
|
|
let segment = segment.trim();
|
|
if let Some(url) = segment.strip_prefix("proxy-ep=") {
|
|
proxy_ep = Some(url.to_string());
|
|
}
|
|
}
|
|
|
|
(raw.to_string(), proxy_ep)
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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.
|
|
pub struct CopilotDriver {
|
|
github_token: Zeroizing<String>,
|
|
token_cache: CopilotTokenCache,
|
|
}
|
|
|
|
impl CopilotDriver {
|
|
pub fn new(github_token: String, _base_url: String) -> Self {
|
|
Self {
|
|
github_token: Zeroizing::new(github_token),
|
|
token_cache: CopilotTokenCache::new(),
|
|
}
|
|
}
|
|
|
|
/// Get a valid Copilot API token, exchanging if needed.
|
|
async fn ensure_token(&self) -> Result<CachedToken, crate::llm_driver::LlmError> {
|
|
// Check cache first
|
|
if let Some(cached) = self.token_cache.get() {
|
|
return Ok(cached);
|
|
}
|
|
|
|
// Exchange GitHub PAT for Copilot token
|
|
debug!("Copilot token expired or missing, exchanging...");
|
|
let token = exchange_copilot_token(&self.github_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)
|
|
}
|
|
|
|
/// 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() {
|
|
GITHUB_COPILOT_BASE_URL.to_string()
|
|
} else {
|
|
token.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(),
|
|
),
|
|
],
|
|
)
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl crate::llm_driver::LlmDriver for CopilotDriver {
|
|
async fn complete(
|
|
&self,
|
|
request: crate::llm_driver::CompletionRequest,
|
|
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError> {
|
|
let token = self.ensure_token().await?;
|
|
let driver = self.make_inner_driver(&token);
|
|
driver.complete(request).await
|
|
}
|
|
|
|
async fn stream(
|
|
&self,
|
|
request: crate::llm_driver::CompletionRequest,
|
|
tx: tokio::sync::mpsc::Sender<crate::llm_driver::StreamEvent>,
|
|
) -> Result<crate::llm_driver::CompletionResponse, crate::llm_driver::LlmError> {
|
|
let token = self.ensure_token().await?;
|
|
let driver = self.make_inner_driver(&token);
|
|
driver.stream(request, tx).await
|
|
}
|
|
}
|
|
|
|
#[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()));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[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(),
|
|
};
|
|
cache.set(token);
|
|
let cached = cache.get();
|
|
assert!(cached.is_some());
|
|
assert_eq!(*cached.unwrap().token, "test-token");
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_validity_check() {
|
|
// Valid token (expires in 1 hour)
|
|
let valid = CachedToken {
|
|
token: Zeroizing::new("t".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()),
|
|
expires_at: Instant::now() + Duration::from_secs(60),
|
|
base_url: GITHUB_COPILOT_BASE_URL.to_string(),
|
|
};
|
|
assert!(!almost_expired.is_valid());
|
|
}
|
|
|
|
#[test]
|
|
fn test_copilot_base_url() {
|
|
assert_eq!(GITHUB_COPILOT_BASE_URL, "https://api.githubcopilot.com");
|
|
}
|
|
}
|