fix: rewrite Copilot driver with OAuth device flow authentication

The Copilot LLM driver was broken - it expected users to provide a
GITHUB_TOKEN env var, but no standard token type (PAT, gh CLI token)
works with the Copilot token exchange endpoint.

Changes:
- Full rewrite of copilot.rs with OAuth device flow using Copilot's
  client ID (Iv1.b507a08c87ecfe98)
- Three-layer token chain: ghu_ (8h) -> Copilot API token (30min),
  with automatic caching and refresh
- Dynamic model fetching from Copilot API on daemon startup and on
  model_not_supported error
- Init wizard: TUI auth screen with device code display, live model
  picker after authentication
- set-key command: interactive device flow for github-copilot provider
- Doctor: detects Copilot auth via persisted token file
- Removed static Copilot model entries (now fetched dynamically)
- Simplified driver instantiation (no env vars needed)

Tested end-to-end with Copilot Enterprise: auth, token exchange,
43 models fetched, completions working with claude-opus-4.6-1m.

Closes #1014

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dmitry Butko
2026-04-09 13:01:07 +10:00
co-authored by Copilot
parent a26f762635
commit c1a14e884e
6 changed files with 1175 additions and 228 deletions
+33
View File
@@ -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: "));
@@ -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<String>),
}
/// 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::<bool>();
let (migrate_tx, migrate_rx) =
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
let (copilot_tx, copilot_rx) =
std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
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,
+48
View File
@@ -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<Vec<String>, 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<Self> {
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
File diff suppressed because it is too large Load Diff
+18 -22
View File
@@ -139,8 +139,8 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
}),
"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<Arc<dyn LlmDriver>, 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
+21 -34
View File
@@ -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<String, String> {
("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<ModelCatalogEntry> {
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)
// ══════════════════════════════════════════════════════════════