mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
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:
co-authored by
Fallenwood
Copilot
fallenwood
Claude Opus 4.6
parent
1d6f7d5085
commit
6232609080
+8
-1
@@ -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
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user