feat(llm): add GitHub Copilot as LLM provider (#1512)

* Add github copilot as LLM provider.

* Fix Copilot in Openclaw

* security: harden Copilot OAuth token handling

C1: Use secrecy::SecretString for oauth_token and cached session token
    in CopilotTokenManager/CachedCopilotToken. Expose only at HTTP
    header injection point via .expose_secret().

C2: Document risks of hardcoded VS Code OAuth client ID and editor
    identity headers (ToS, rotation, staleness). Remove the unreliable
    paste-token setup path (setup_github_copilot_manual_token).

C3: Fix TOCTOU race in get_token() — re-check token validity after
    acquiring write lock so concurrent callers don't all perform
    redundant token exchanges.

I1: Remove dead empty else {} block in get_token().

I2: Map 401 responses to LlmError::AuthFailed instead of RequestFailed
    so retry/circuit-breaker logic handles auth failures correctly.

I3: Replace prepare_github_copilot_setup() with call to existing
    set_llm_backend_preserving_model() helper to avoid logic drift.

I4: Add unit tests for CopilotTokenManager (caching, invalidation,
    expiry/buffer behavior), poll response parsing (all OAuth device
    flow states), and DeviceCodeResponse/CopilotTokenResponse deserialization.

Co-authored-by: Copilot <[email protected]>

* fix: address review feedback and code improvements (takeover #1202)

- Fix ContentPart::Text being silently dropped in convert_messages
- Replace custom truncate_for_error with crate::util::floor_char_boundary
- Fix CLAUDE.md: accurately describe dedicated provider (not "OpenAI-compatible path")
- Fix "Github" -> "GitHub" capitalization in READMEs
- Add manual token paste option to setup wizard (not just device login)
- Fix missing extension_manager field in EngineContext (merge fixup)
- cargo fmt applied

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback for GitHub Copilot provider

- Plumb request_timeout_secs into GithubCopilotProvider (was hardcoded 120s)
- Forward stop_sequences to Copilot API via OpenAI `stop` field
- Skip empty text part in multimodal message conversion
- Improve paste-token wizard hint with specific file path guidance

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: 401 retry, retryable token exchange errors, shared retry-after parsing

- Retry once inline on 401 after token invalidation (was returning
  AuthFailed immediately, guaranteeing user-visible failure)
- Map token exchange failures to RequestFailed (retryable) instead of
  AuthFailed (non-retryable by RetryProvider)
- Use shared crate::llm::retry::parse_retry_after for HTTP-date support
  and safe 60s default
- Improve paste-token wizard hint: mention `gh auth token` as primary source

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: 401 retry error mapping, retry status logging, token whitespace safety

- Map 401 retry get_token() failure to RequestFailed (retryable),
  consistent with initial token acquisition path
- Log retry response status before returning AuthFailed
- Trim oauth_token in exchange_copilot_token to prevent header panics
  from whitespace in env vars

Co-Authored-By: fallenwood <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Fallenwood <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: fallenwood <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-21 00:02:00 -07:00
committed by GitHub
co-authored by Fallenwood Copilot fallenwood Claude Opus 4.6
parent 1d6f7d5085
commit 6232609080
16 changed files with 1794 additions and 6 deletions
+8 -1
View File
@@ -218,6 +218,7 @@ env-var mode or skipped secrets.
| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` |
| Anthropic | API key | `llm_anthropic_api_key` | `ANTHROPIC_API_KEY` |
| OpenAI | API key | `llm_openai_api_key` | `OPENAI_API_KEY` |
| GitHub Copilot | OAuth token | `llm_github_copilot_token` | `GITHUB_COPILOT_TOKEN` |
| Ollama | None | - | - |
| OpenRouter | API key | `llm_openrouter_api_key` | `OPENROUTER_API_KEY` |
| OpenAI-compatible | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` |
@@ -240,6 +241,12 @@ with its own secret name and env var. It is **not** stored as `openai_compatible
5. Preserve `selected_model` on a same-backend re-run; clear it only when
switching to a different backend
**GitHub Copilot** (`setup_github_copilot`):
- Offers **GitHub device login** (recommended) or manual token paste
- Device login uses the VS Code Copilot OAuth client and stores the resulting token as `llm_github_copilot_token`
- Validates the token against `https://api.githubcopilot.com/models` before saving
- Injects `GITHUB_COPILOT_TOKEN` into the config overlay for immediate provider use
**NEAR AI** (`setup_nearai`):
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
- Options 1-2 (GitHub/Google): browser OAuth → **NEAR AI Chat** mode
@@ -530,7 +537,7 @@ pub struct Settings {
pub secrets_master_key_source: KeySource, // Keychain | Env | None
// Step 3: Inference
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "bedrock"
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "github_copilot" | "ollama" | "openai_compatible" | "bedrock"
pub ollama_base_url: Option<String>,
pub openai_compatible_base_url: Option<String>,
+129 -1
View File
@@ -3,7 +3,7 @@
//! The wizard guides users through:
//! 1. Database connection
//! 2. Security (secrets master key)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, OpenAI Codex, Ollama, OpenAI-compatible)
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, GitHub Copilot, OpenAI Codex, Ollama, OpenAI-compatible)
//! 4. Model selection
//! 5. Embeddings
//! 6. Channel configuration
@@ -1191,6 +1191,10 @@ impl SetupWizard {
return self.setup_anthropic().await;
}
if provider_id == "github_copilot" {
return self.setup_github_copilot().await;
}
match setup {
crate::llm::registry::SetupHint::ApiKey {
secret_name,
@@ -1353,6 +1357,100 @@ impl SetupWizard {
}
}
async fn setup_github_copilot(&mut self) -> Result<(), SetupError> {
print_info("GitHub Copilot authentication:");
let options = &[
"GitHub device login (recommended)",
"Paste an existing token (from IDE or personal access token)",
];
let choice = select_one("Auth method:", options).map_err(SetupError::Io)?;
match choice {
0 => self.setup_github_copilot_device_login().await,
_ => self.setup_github_copilot_paste_token().await,
}
}
async fn setup_github_copilot_paste_token(&mut self) -> Result<(), SetupError> {
self.set_llm_backend_preserving_model("github_copilot");
print_info("Paste your GitHub token (requires an active Copilot subscription).");
print_info("Sources: `gh auth token`, or the oauth_token field in");
print_info("~/.config/github-copilot/apps.json (VS Code) or ~/.config/gh/hosts.yml.");
let token_secret = secret_input("GitHub Copilot token").map_err(SetupError::Io)?;
let token = token_secret.expose_secret().trim().to_string();
if token.is_empty() {
return Err(SetupError::Auth("No token provided".to_string()));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| SetupError::Auth(format!("Failed to create HTTP client: {e}")))?;
self.save_github_copilot_token(&client, &token).await
}
async fn setup_github_copilot_device_login(&mut self) -> Result<(), SetupError> {
self.set_llm_backend_preserving_model("github_copilot");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| SetupError::Auth(format!("Failed to create HTTP client: {e}")))?;
let device = crate::llm::github_copilot_auth::request_device_code(&client)
.await
.map_err(|e| SetupError::Auth(e.to_string()))?;
print_info("Authorize IronClaw with GitHub Copilot in your browser.");
print_info(&format!("Verification URL: {}", device.verification_uri));
print_info(&format!("One-time code: {}", device.user_code));
if let Err(e) = open::that(&device.verification_uri) {
tracing::debug!(
url = %device.verification_uri,
error = %e,
"Failed to open GitHub Copilot device login URL"
);
print_info("Open the URL above manually if your browser did not launch.");
} else {
print_info("Opened your browser to GitHub device login.");
}
print_info("Waiting for GitHub authorization...");
let token = crate::llm::github_copilot_auth::wait_for_device_login(&client, &device)
.await
.map_err(|e| SetupError::Auth(e.to_string()))?;
self.save_github_copilot_token(&client, &token).await
}
async fn save_github_copilot_token(
&mut self,
client: &reqwest::Client,
token: &str,
) -> Result<(), SetupError> {
crate::llm::github_copilot_auth::validate_token(client, token)
.await
.map_err(|e| SetupError::Auth(e.to_string()))?;
if let Ok(ctx) = self.init_secrets_context().await {
let key = SecretString::from(token.to_string());
ctx.save_secret("llm_github_copilot_token", &key)
.await
.map_err(|e| SetupError::Config(format!("Failed to save GitHub token: {e}")))?;
print_success("GitHub Copilot token encrypted and saved");
} else {
print_info("Secrets not available. Set GITHUB_COPILOT_TOKEN in your environment.");
}
crate::config::inject_single_var("GITHUB_COPILOT_TOKEN", token);
self.llm_api_key = Some(SecretString::from(token.to_string()));
print_success("GitHub Copilot configured");
Ok(())
}
/// Anthropic OAuth setup: extract token from `claude login` credentials.
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
self.set_llm_backend_preserving_model("anthropic");
@@ -3508,6 +3606,36 @@ mod tests {
);
}
#[test]
fn test_github_copilot_setup_preserves_model_for_same_backend() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("github_copilot".to_string());
wizard.settings.selected_model = Some("gpt-4o".to_string());
wizard.set_llm_backend_preserving_model("github_copilot");
assert_eq!(wizard.settings.selected_model.as_deref(), Some("gpt-4o"));
assert_eq!(
wizard.settings.llm_backend.as_deref(),
Some("github_copilot")
);
}
#[test]
fn test_github_copilot_setup_clears_stale_model_on_switch() {
let mut wizard = SetupWizard::new();
wizard.settings.llm_backend = Some("openai".to_string());
wizard.settings.selected_model = Some("gpt-5".to_string());
wizard.set_llm_backend_preserving_model("github_copilot");
assert!(wizard.settings.selected_model.is_none());
assert_eq!(
wizard.settings.llm_backend.as_deref(),
Some("github_copilot")
);
}
#[test]
fn test_is_openai_chat_model_includes_gpt5_and_filters_non_chat_variants() {
assert!(is_openai_chat_model("gpt-5"));