mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +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
+12
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
|||||||
|
|
||||||
# LLM Provider
|
# LLM Provider
|
||||||
# LLM_BACKEND=nearai # default
|
# LLM_BACKEND=nearai # default
|
||||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil, openai_codex
|
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
|
||||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||||
|
|
||||||
# === Anthropic Direct ===
|
# === Anthropic Direct ===
|
||||||
@@ -24,6 +24,17 @@ DATABASE_POOL_SIZE=10
|
|||||||
# LLM_USE_CODEX_AUTH=true
|
# LLM_USE_CODEX_AUTH=true
|
||||||
# CODEX_AUTH_PATH=~/.codex/auth.json
|
# CODEX_AUTH_PATH=~/.codex/auth.json
|
||||||
|
|
||||||
|
# === GitHub Copilot ===
|
||||||
|
# Uses the OAuth token from your Copilot IDE sign-in (for example
|
||||||
|
# ~/.config/github-copilot/apps.json on Linux/macOS), or run `ironclaw onboard`
|
||||||
|
# and choose the GitHub device login flow.
|
||||||
|
# LLM_BACKEND=github_copilot
|
||||||
|
# GITHUB_COPILOT_TOKEN=gho_...
|
||||||
|
# GITHUB_COPILOT_MODEL=gpt-4o
|
||||||
|
# IronClaw injects standard VS Code Copilot headers automatically.
|
||||||
|
# Optional advanced headers for custom overrides:
|
||||||
|
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
|
||||||
|
|
||||||
# === NEAR AI (Chat Completions API) ===
|
# === NEAR AI (Chat Completions API) ===
|
||||||
# Two auth modes:
|
# Two auth modes:
|
||||||
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||||
|
| GitHub Copilot | ✅ | ✅ | - | Dedicated provider with OAuth token exchange (`GithubCopilotProvider`) |
|
||||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||||
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
||||||
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ written to `~/.ironclaw/.env` so they are available before the database connects
|
|||||||
### Alternative LLM Providers
|
### Alternative LLM Providers
|
||||||
|
|
||||||
IronClaw defaults to NEAR AI but supports many LLM providers out of the box.
|
IronClaw defaults to NEAR AI but supports many LLM providers out of the box.
|
||||||
Built-in providers include **Anthropic**, **OpenAI**, **Google Gemini**, **MiniMax**,
|
Built-in providers include **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
|
||||||
**Mistral**, and **Ollama** (local). OpenAI-compatible services like **OpenRouter**
|
**Mistral**, and **Ollama** (local). OpenAI-compatible services like **OpenRouter**
|
||||||
(300+ models), **Together AI**, **Fireworks AI**, and self-hosted servers (**vLLM**,
|
(300+ models), **Together AI**, **Fireworks AI**, and self-hosted servers (**vLLM**,
|
||||||
**LiteLLM**) are also supported.
|
**LiteLLM**) are also supported.
|
||||||
|
|||||||
+1
-1
@@ -165,7 +165,7 @@ ironclaw onboard
|
|||||||
### 替代 LLM 提供商
|
### 替代 LLM 提供商
|
||||||
|
|
||||||
IronClaw 默认使用 NEAR AI,但开箱即用地支持多种 LLM 提供商。
|
IronClaw 默认使用 NEAR AI,但开箱即用地支持多种 LLM 提供商。
|
||||||
内置提供商包括 **Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
|
内置提供商包括 **Anthropic**、**OpenAI**、**GitHub Copilot**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
|
||||||
|
|
||||||
在向导中选择你的提供商,或直接设置环境变量:
|
在向导中选择你的提供商,或直接设置环境变量:
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ configurations.
|
|||||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||||
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
|
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
|
||||||
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
||||||
|
| GitHub Copilot | `github_copilot` | `GITHUB_COPILOT_TOKEN` | Multi-models |
|
||||||
| Ollama | `ollama` | No | Local inference |
|
| Ollama | `ollama` | No | Local inference |
|
||||||
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
||||||
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
||||||
@@ -61,6 +62,34 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GitHub Copilot
|
||||||
|
|
||||||
|
GitHub Copilot exposes chat endpoint at
|
||||||
|
`https://api.githubcopilot.com`. IronClaw uses that endpoint directly through the
|
||||||
|
built-in `github_copilot` provider.
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_BACKEND=github_copilot
|
||||||
|
GITHUB_COPILOT_TOKEN=gho_...
|
||||||
|
GITHUB_COPILOT_MODEL=gpt-4o
|
||||||
|
# Optional advanced headers if your setup needs them:
|
||||||
|
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
|
||||||
|
```
|
||||||
|
|
||||||
|
`ironclaw onboard` can acquire this token for you using GitHub device login. If you
|
||||||
|
already signed into Copilot through VS Code or a JetBrains IDE, you can also reuse
|
||||||
|
the `oauth_token` stored in `~/.config/github-copilot/apps.json`. If you prefer,
|
||||||
|
`LLM_BACKEND=github-copilot` also works as an alias.
|
||||||
|
|
||||||
|
Popular models vary by subscription, but `gpt-4o` is a safe default. IronClaw keeps
|
||||||
|
model entry manual for this provider because GitHub Copilot model listing may require
|
||||||
|
extra integration headers on some clients. IronClaw automatically injects the standard
|
||||||
|
VS Code identity headers (`User-Agent`, `Editor-Version`, `Editor-Plugin-Version`,
|
||||||
|
`Copilot-Integration-Id`) and lets you override them with
|
||||||
|
`GITHUB_COPILOT_EXTRA_HEADERS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Ollama (local)
|
## Ollama (local)
|
||||||
|
|
||||||
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
||||||
|
|||||||
@@ -77,6 +77,29 @@
|
|||||||
"can_list_models": false
|
"can_list_models": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "github_copilot",
|
||||||
|
"aliases": [
|
||||||
|
"github-copilot",
|
||||||
|
"githubcopilot",
|
||||||
|
"copilot"
|
||||||
|
],
|
||||||
|
"protocol": "github_copilot",
|
||||||
|
"default_base_url": "https://api.githubcopilot.com",
|
||||||
|
"api_key_env": "GITHUB_COPILOT_TOKEN",
|
||||||
|
"api_key_required": true,
|
||||||
|
"model_env": "GITHUB_COPILOT_MODEL",
|
||||||
|
"default_model": "gpt-4o",
|
||||||
|
"extra_headers_env": "GITHUB_COPILOT_EXTRA_HEADERS",
|
||||||
|
"description": "GitHub Copilot Chat API (OAuth token from IDE sign-in)",
|
||||||
|
"setup": {
|
||||||
|
"kind": "api_key",
|
||||||
|
"secret_name": "llm_github_copilot_token",
|
||||||
|
"key_url": "https://docs.github.com/en/copilot",
|
||||||
|
"display_name": "GitHub Copilot",
|
||||||
|
"can_list_models": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "tinfoil",
|
"id": "tinfoil",
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
|
|||||||
@@ -389,6 +389,14 @@ impl LlmConfig {
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
let extra_headers = if canonical_id == "github_copilot" {
|
||||||
|
merge_extra_headers(
|
||||||
|
crate::llm::github_copilot_auth::default_headers(),
|
||||||
|
extra_headers,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extra_headers
|
||||||
|
};
|
||||||
|
|
||||||
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
|
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
|
||||||
// Only check for OAuth token when the provider is actually Anthropic.
|
// Only check for OAuth token when the provider is actually Anthropic.
|
||||||
@@ -473,6 +481,26 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
|||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_extra_headers(
|
||||||
|
defaults: Vec<(String, String)>,
|
||||||
|
overrides: Vec<(String, String)>,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
let mut positions = std::collections::HashMap::<String, usize>::new();
|
||||||
|
|
||||||
|
for (key, value) in defaults.into_iter().chain(overrides) {
|
||||||
|
let normalized = key.to_ascii_lowercase();
|
||||||
|
if let Some(existing_index) = positions.get(&normalized).copied() {
|
||||||
|
merged[existing_index] = (key, value);
|
||||||
|
} else {
|
||||||
|
positions.insert(normalized, merged.len());
|
||||||
|
merged.push((key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the default session file path (~/.ironclaw/session.json).
|
/// Get the default session file path (~/.ironclaw/session.json).
|
||||||
pub fn default_session_path() -> PathBuf {
|
pub fn default_session_path() -> PathBuf {
|
||||||
ironclaw_base_dir().join("session.json")
|
ironclaw_base_dir().join("session.json")
|
||||||
@@ -604,6 +632,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_extra_headers_prefers_overrides_case_insensitively() {
|
||||||
|
let merged = merge_extra_headers(
|
||||||
|
vec![
|
||||||
|
("User-Agent".to_string(), "default-agent".to_string()),
|
||||||
|
("X-Test".to_string(), "default".to_string()),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
("user-agent".to_string(), "override-agent".to_string()),
|
||||||
|
("X-Extra".to_string(), "present".to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
merged,
|
||||||
|
vec![
|
||||||
|
("user-agent".to_string(), "override-agent".to_string()),
|
||||||
|
("X-Test".to_string(), "default".to_string()),
|
||||||
|
("X-Extra".to_string(), "present".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear all ollama-related env vars.
|
/// Clear all ollama-related env vars.
|
||||||
fn clear_ollama_env() {
|
fn clear_ollama_env() {
|
||||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
@@ -756,6 +807,54 @@ mod tests {
|
|||||||
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_provider_resolves_github_copilot_alias() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LLM_BACKEND", "github-copilot");
|
||||||
|
std::env::set_var("GITHUB_COPILOT_TOKEN", "gho_test_token");
|
||||||
|
std::env::set_var(
|
||||||
|
"GITHUB_COPILOT_EXTRA_HEADERS",
|
||||||
|
"Copilot-Integration-Id:custom-chat,X-Test:enabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = Settings::default();
|
||||||
|
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
assert_eq!(cfg.backend, "github_copilot");
|
||||||
|
let provider = cfg.provider.expect("provider config should be present");
|
||||||
|
assert_eq!(provider.provider_id, "github_copilot");
|
||||||
|
assert_eq!(provider.base_url, "https://api.githubcopilot.com");
|
||||||
|
assert_eq!(provider.model, "gpt-4o");
|
||||||
|
assert!(
|
||||||
|
provider
|
||||||
|
.extra_headers
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| { key == "Copilot-Integration-Id" && value == "custom-chat" })
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
provider
|
||||||
|
.extra_headers
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key == "User-Agent" && value == "GitHubCopilotChat/0.26.7")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
provider
|
||||||
|
.extra_headers
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key == "X-Test" && value == "enabled")
|
||||||
|
);
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("LLM_BACKEND");
|
||||||
|
std::env::remove_var("GITHUB_COPILOT_TOKEN");
|
||||||
|
std::env::remove_var("GITHUB_COPILOT_EXTRA_HEADERS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nearai_backend_has_no_registry_provider() {
|
fn nearai_backend_has_no_registry_provider() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Set via `LLM_BACKEND` env var:
|
|||||||
| `nearai` (default) | NEAR AI Chat Completions | `NEARAI_SESSION_TOKEN` or `NEARAI_API_KEY` |
|
| `nearai` (default) | NEAR AI Chat Completions | `NEARAI_SESSION_TOKEN` or `NEARAI_API_KEY` |
|
||||||
| `openai` | OpenAI | `OPENAI_API_KEY` |
|
| `openai` | OpenAI | `OPENAI_API_KEY` |
|
||||||
| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` |
|
| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` |
|
||||||
|
| `github_copilot` | GitHub Copilot Chat API | `GITHUB_COPILOT_TOKEN`, `GITHUB_COPILOT_MODEL` |
|
||||||
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
|
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
|
||||||
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||||
@@ -60,6 +61,27 @@ Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requir
|
|||||||
- `BEDROCK_MODEL` — Required model ID (e.g., `anthropic.claude-opus-4-6-v1`)
|
- `BEDROCK_MODEL` — Required model ID (e.g., `anthropic.claude-opus-4-6-v1`)
|
||||||
- `BEDROCK_CROSS_REGION` — Optional cross-region inference prefix (`us`, `eu`, `apac`, `global`)
|
- `BEDROCK_CROSS_REGION` — Optional cross-region inference prefix (`us`, `eu`, `apac`, `global`)
|
||||||
|
|
||||||
|
## GitHub Copilot Provider Notes
|
||||||
|
|
||||||
|
`github_copilot` uses a dedicated `GithubCopilotProvider` (`github_copilot.rs`) with
|
||||||
|
direct HTTP via `reqwest::Client`. It cannot use `RigAdapter` because the Copilot API
|
||||||
|
requires a two-step authentication flow: a long-lived GitHub OAuth token is exchanged
|
||||||
|
for a short-lived Copilot session token via `api.github.com/copilot_internal/v2/token`.
|
||||||
|
The session token is cached and auto-refreshed before expiry by `CopilotTokenManager`
|
||||||
|
in `github_copilot_auth.rs`.
|
||||||
|
|
||||||
|
The API endpoint is `https://api.githubcopilot.com/chat/completions` (OpenAI Chat
|
||||||
|
Completions format). Token source: `GITHUB_COPILOT_TOKEN` env var, or the
|
||||||
|
`oauth_token` from your IDE sign-in flow (`~/.config/github-copilot/apps.json`).
|
||||||
|
The setup wizard supports GitHub device login or manual token paste.
|
||||||
|
|
||||||
|
**Known risk:** The device login flow uses the VS Code Copilot OAuth client ID
|
||||||
|
(`Iv1.b507a08c87ecfe98`) and injects VS Code identity headers (`User-Agent`,
|
||||||
|
`Editor-Version`, `Editor-Plugin-Version`, `Copilot-Integration-Id`). GitHub could
|
||||||
|
rotate this client ID at any time. If GitHub publishes an official third-party client
|
||||||
|
ID, migrate to it immediately. Advanced users can override headers via
|
||||||
|
`GITHUB_COPILOT_EXTRA_HEADERS`.
|
||||||
|
|
||||||
## NEAR AI Provider Gotchas
|
## NEAR AI Provider Gotchas
|
||||||
|
|
||||||
**Dual auth modes:**
|
**Dual auth modes:**
|
||||||
|
|||||||
@@ -0,0 +1,712 @@
|
|||||||
|
//! GitHub Copilot provider (direct HTTP with token exchange).
|
||||||
|
//!
|
||||||
|
//! The GitHub Copilot API at `api.githubcopilot.com` speaks OpenAI Chat
|
||||||
|
//! Completions format but requires a two-step authentication flow:
|
||||||
|
//! 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
|
||||||
|
//! 2. A short-lived Copilot session token (exchanged via GitHub API)
|
||||||
|
//!
|
||||||
|
//! The standard OpenAI rig-core client sends `Authorization: Bearer <token>`
|
||||||
|
//! with the raw OAuth token, which gets rejected with "Authorization header
|
||||||
|
//! is badly formatted". This provider handles the token exchange transparently.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use reqwest::Client;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::llm::config::RegistryProviderConfig;
|
||||||
|
use crate::llm::costs;
|
||||||
|
use crate::llm::error::LlmError;
|
||||||
|
use crate::llm::github_copilot_auth::CopilotTokenManager;
|
||||||
|
use crate::llm::provider::{
|
||||||
|
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, LlmProvider,
|
||||||
|
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||||
|
strip_unsupported_completion_params, strip_unsupported_tool_params,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// GitHub Copilot provider with automatic token exchange.
|
||||||
|
pub struct GithubCopilotProvider {
|
||||||
|
client: Client,
|
||||||
|
token_manager: Arc<CopilotTokenManager>,
|
||||||
|
model: String,
|
||||||
|
base_url: String,
|
||||||
|
active_model: std::sync::RwLock<String>,
|
||||||
|
extra_headers: Vec<(String, String)>,
|
||||||
|
/// Parameter names that this provider does not support.
|
||||||
|
unsupported_params: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GithubCopilotProvider {
|
||||||
|
pub fn new(
|
||||||
|
config: &RegistryProviderConfig,
|
||||||
|
request_timeout_secs: u64,
|
||||||
|
) -> Result<Self, LlmError> {
|
||||||
|
let oauth_token = config
|
||||||
|
.api_key
|
||||||
|
.as_ref()
|
||||||
|
.map(|k| k.expose_secret().to_string())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
tracing::error!("No API key configured for github_copilot — check GITHUB_COPILOT_TOKEN env var or secrets store");
|
||||||
|
LlmError::AuthFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(request_timeout_secs))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Failed to build HTTP client: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let token_manager = Arc::new(CopilotTokenManager::new(client.clone(), oauth_token));
|
||||||
|
|
||||||
|
let base_url = if config.base_url.is_empty() {
|
||||||
|
"https://api.githubcopilot.com".to_string()
|
||||||
|
} else {
|
||||||
|
config.base_url.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||||
|
let unsupported_params: HashSet<String> =
|
||||||
|
config.unsupported_params.iter().cloned().collect();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
token_manager,
|
||||||
|
model: config.model.clone(),
|
||||||
|
base_url,
|
||||||
|
active_model,
|
||||||
|
extra_headers: config.extra_headers.clone(),
|
||||||
|
unsupported_params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_url(&self) -> String {
|
||||||
|
let base = self.base_url.trim_end_matches('/');
|
||||||
|
format!("{base}/chat/completions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||||
|
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||||
|
strip_unsupported_completion_params(&self.unsupported_params, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||||
|
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||||
|
strip_unsupported_tool_params(&self.unsupported_params, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_request<R: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
body: &impl Serialize,
|
||||||
|
) -> Result<R, LlmError> {
|
||||||
|
let url = self.api_url();
|
||||||
|
// Map token exchange failures to RequestFailed (retryable) rather than
|
||||||
|
// AuthFailed (non-retryable), since transient network errors during
|
||||||
|
// exchange should be retried by RetryProvider.
|
||||||
|
let token = self.token_manager.get_token().await.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e, "Copilot: token exchange failed");
|
||||||
|
LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Token exchange failed: {e}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut request = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token.expose_secret())
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Inject Copilot identity headers
|
||||||
|
for (key, value) in &self.extra_headers {
|
||||||
|
request = request.header(key.as_str(), value.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.json(body).send().await.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e, "Copilot: HTTP request failed");
|
||||||
|
LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
// Use shared retry-after parser (supports HTTP-date, default 60s)
|
||||||
|
let retry_after = Some(crate::llm::retry::parse_retry_after(
|
||||||
|
response.headers().get(reqwest::header::RETRY_AFTER),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| format!("(failed to read error body: {e})"));
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
status = %status,
|
||||||
|
body = %crate::agent::truncate_for_preview(&response_text, 256),
|
||||||
|
"Copilot: API error response"
|
||||||
|
);
|
||||||
|
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
// Invalidate the cached session token and retry once with a
|
||||||
|
// fresh exchange — stale tokens are the most common 401 cause.
|
||||||
|
tracing::warn!("Copilot: 401 Unauthorized — invalidating session token, retrying");
|
||||||
|
self.token_manager.invalidate().await;
|
||||||
|
let fresh = self.token_manager.get_token().await.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e, "Copilot: re-exchange after 401 failed");
|
||||||
|
LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Token re-exchange after 401 failed: {e}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let mut retry_req = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(fresh.expose_secret())
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
for (key, value) in &self.extra_headers {
|
||||||
|
retry_req = retry_req.header(key.as_str(), value.as_str());
|
||||||
|
}
|
||||||
|
let retry =
|
||||||
|
retry_req
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Retry after 401 failed: {e}"),
|
||||||
|
})?;
|
||||||
|
if retry.status().is_success() {
|
||||||
|
let text = retry.text().await.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Failed to read retry response body: {e}"),
|
||||||
|
})?;
|
||||||
|
return serde_json::from_str(&text).map_err(|e| {
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&text, 512);
|
||||||
|
LlmError::InvalidResponse {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let retry_status = retry.status();
|
||||||
|
tracing::warn!(
|
||||||
|
status = %retry_status,
|
||||||
|
"Copilot: 401 retry also failed"
|
||||||
|
);
|
||||||
|
return Err(LlmError::AuthFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.as_u16() == 429 {
|
||||||
|
tracing::warn!(retry_after = ?retry_after, "Copilot: rate limited");
|
||||||
|
return Err(LlmError::RateLimited {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
retry_after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||||
|
return Err(LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("HTTP {status}: {truncated}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("Failed to read response body: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
serde_json::from_str(&response_text).map_err(|e| {
|
||||||
|
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
body = %truncated,
|
||||||
|
"Copilot: failed to parse response JSON"
|
||||||
|
);
|
||||||
|
LlmError::InvalidResponse {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for GithubCopilotProvider {
|
||||||
|
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
|
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||||
|
self.strip_unsupported_completion_params(&mut req);
|
||||||
|
let messages = convert_messages(req.messages);
|
||||||
|
|
||||||
|
let request = OpenAiRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
temperature: req.temperature,
|
||||||
|
stop: req.stop_sequences,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: OpenAiResponse = self.send_request(&request).await?;
|
||||||
|
let choice =
|
||||||
|
response
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| LlmError::InvalidResponse {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: "No choices in response".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (content, _tool_calls) = extract_choice_content(&choice);
|
||||||
|
|
||||||
|
let finish_reason = match choice.finish_reason.as_deref() {
|
||||||
|
Some("stop") => FinishReason::Stop,
|
||||||
|
Some("length") => FinishReason::Length,
|
||||||
|
Some("tool_calls") => FinishReason::ToolUse,
|
||||||
|
Some("content_filter") => FinishReason::ContentFilter,
|
||||||
|
_ => FinishReason::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CompletionResponse {
|
||||||
|
content: content.unwrap_or_default(),
|
||||||
|
finish_reason,
|
||||||
|
input_tokens: response
|
||||||
|
.usage
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.prompt_tokens)
|
||||||
|
.unwrap_or(0),
|
||||||
|
output_tokens: response
|
||||||
|
.usage
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.completion_tokens)
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_with_tools(
|
||||||
|
&self,
|
||||||
|
mut req: ToolCompletionRequest,
|
||||||
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
|
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||||
|
self.strip_unsupported_tool_params(&mut req);
|
||||||
|
let messages = convert_messages(req.messages);
|
||||||
|
|
||||||
|
let tools: Vec<OpenAiTool> = req
|
||||||
|
.tools
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| OpenAiTool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: OpenAiFunction {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: t.parameters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tool_choice = req.tool_choice.map(|tc| match tc.as_str() {
|
||||||
|
"auto" | "required" | "none" => serde_json::Value::String(tc),
|
||||||
|
specific => serde_json::json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": specific}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = OpenAiRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
temperature: req.temperature,
|
||||||
|
stop: req.stop_sequences,
|
||||||
|
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||||
|
tool_choice,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: OpenAiResponse = self.send_request(&request).await?;
|
||||||
|
let choice =
|
||||||
|
response
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| LlmError::InvalidResponse {
|
||||||
|
provider: "github_copilot".to_string(),
|
||||||
|
reason: "No choices in response".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (content, tool_calls) = extract_choice_content(&choice);
|
||||||
|
|
||||||
|
let finish_reason = match choice.finish_reason.as_deref() {
|
||||||
|
Some("stop") => FinishReason::Stop,
|
||||||
|
Some("length") => FinishReason::Length,
|
||||||
|
Some("tool_calls") => FinishReason::ToolUse,
|
||||||
|
Some("content_filter") => FinishReason::ContentFilter,
|
||||||
|
_ => {
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
FinishReason::ToolUse
|
||||||
|
} else {
|
||||||
|
FinishReason::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ToolCompletionResponse {
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
finish_reason,
|
||||||
|
input_tokens: response
|
||||||
|
.usage
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.prompt_tokens)
|
||||||
|
.unwrap_or(0),
|
||||||
|
output_tokens: response
|
||||||
|
.usage
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.completion_tokens)
|
||||||
|
.unwrap_or(0),
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_name(&self) -> &str {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||||
|
let model = self.active_model_name();
|
||||||
|
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_model_name(&self) -> String {
|
||||||
|
match self.active_model.read() {
|
||||||
|
Ok(guard) => guard.clone(),
|
||||||
|
Err(poisoned) => poisoned.into_inner().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||||
|
match self.active_model.write() {
|
||||||
|
Ok(mut guard) => {
|
||||||
|
*guard = model.to_string();
|
||||||
|
}
|
||||||
|
Err(poisoned) => {
|
||||||
|
*poisoned.into_inner() = model.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI Chat Completions API types ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<OpenAiMessage>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
max_tokens: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
temperature: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
stop: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tools: Option<Vec<OpenAiTool>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_choice: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiMessage {
|
||||||
|
role: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<OpenAiContent>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_calls: Option<Vec<OpenAiToolCall>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI content can be a plain string or an array of parts (for multimodal).
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum OpenAiContent {
|
||||||
|
Text(String),
|
||||||
|
Parts(Vec<OpenAiContentPart>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
enum OpenAiContentPart {
|
||||||
|
#[serde(rename = "text")]
|
||||||
|
Text { text: String },
|
||||||
|
#[serde(rename = "image_url")]
|
||||||
|
ImageUrl { image_url: OpenAiImageUrl },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiImageUrl {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiToolCall {
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
call_type: String,
|
||||||
|
function: OpenAiToolCallFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiToolCallFunction {
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiTool {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
tool_type: String,
|
||||||
|
function: OpenAiFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct OpenAiFunction {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiResponse {
|
||||||
|
choices: Vec<OpenAiChoice>,
|
||||||
|
#[serde(default)]
|
||||||
|
usage: Option<OpenAiUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiChoice {
|
||||||
|
message: OpenAiResponseMessage,
|
||||||
|
#[serde(default)]
|
||||||
|
finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiResponseMessage {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_calls: Option<Vec<OpenAiResponseToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiResponseToolCall {
|
||||||
|
id: String,
|
||||||
|
function: OpenAiResponseFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiResponseFunction {
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OpenAiUsage {
|
||||||
|
#[serde(default)]
|
||||||
|
prompt_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
completion_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert IronClaw messages to OpenAI Chat Completions format.
|
||||||
|
fn convert_messages(messages: Vec<ChatMessage>) -> Vec<OpenAiMessage> {
|
||||||
|
messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|msg| match msg.role {
|
||||||
|
Role::System => OpenAiMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(OpenAiContent::Text(msg.content)),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
Role::User => {
|
||||||
|
let content = if msg.content_parts.is_empty() {
|
||||||
|
Some(OpenAiContent::Text(msg.content))
|
||||||
|
} else {
|
||||||
|
let mut parts = Vec::with_capacity(1 + msg.content_parts.len());
|
||||||
|
if !msg.content.is_empty() {
|
||||||
|
parts.push(OpenAiContentPart::Text { text: msg.content });
|
||||||
|
}
|
||||||
|
for part in msg.content_parts {
|
||||||
|
match part {
|
||||||
|
ContentPart::Text { text } => {
|
||||||
|
parts.push(OpenAiContentPart::Text { text });
|
||||||
|
}
|
||||||
|
ContentPart::ImageUrl { image_url } => {
|
||||||
|
parts.push(OpenAiContentPart::ImageUrl {
|
||||||
|
image_url: OpenAiImageUrl { url: image_url.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(OpenAiContent::Parts(parts))
|
||||||
|
};
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content,
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::Assistant => {
|
||||||
|
let tool_calls = msg.tool_calls.map(|calls| {
|
||||||
|
calls
|
||||||
|
.into_iter()
|
||||||
|
.map(|tc| OpenAiToolCall {
|
||||||
|
id: tc.id,
|
||||||
|
call_type: "function".to_string(),
|
||||||
|
function: OpenAiToolCallFunction {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
let content = if msg.content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(OpenAiContent::Text(msg.content))
|
||||||
|
};
|
||||||
|
OpenAiMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Role::Tool => OpenAiMessage {
|
||||||
|
role: "tool".to_string(),
|
||||||
|
content: Some(OpenAiContent::Text(msg.content)),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: msg.tool_call_id,
|
||||||
|
name: msg.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text and tool calls from an OpenAI response choice.
|
||||||
|
fn extract_choice_content(choice: &OpenAiChoice) -> (Option<String>, Vec<ToolCall>) {
|
||||||
|
let content = choice.message.content.clone();
|
||||||
|
let tool_calls = choice
|
||||||
|
.message
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map(|calls| {
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.map(|tc| ToolCall {
|
||||||
|
id: tc.id.clone(),
|
||||||
|
name: tc.function.name.clone(),
|
||||||
|
arguments: serde_json::from_str(&tc.function.arguments)
|
||||||
|
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
(content, tool_calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_messages_basic() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system("You are helpful."),
|
||||||
|
ChatMessage::user("Hello"),
|
||||||
|
ChatMessage::assistant("Hi there!"),
|
||||||
|
];
|
||||||
|
let converted = convert_messages(messages);
|
||||||
|
assert_eq!(converted.len(), 3);
|
||||||
|
assert_eq!(converted[0].role, "system");
|
||||||
|
assert_eq!(converted[1].role, "user");
|
||||||
|
assert_eq!(converted[2].role, "assistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_messages_tool_calls() {
|
||||||
|
let tool_calls = vec![ToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: serde_json::json!({"q": "test"}),
|
||||||
|
}];
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::user("Search"),
|
||||||
|
ChatMessage::assistant_with_tool_calls(Some("Searching...".to_string()), tool_calls),
|
||||||
|
ChatMessage::tool_result("call_1", "search", "found it"),
|
||||||
|
];
|
||||||
|
let converted = convert_messages(messages);
|
||||||
|
assert_eq!(converted.len(), 3);
|
||||||
|
assert!(converted[1].tool_calls.is_some());
|
||||||
|
assert_eq!(converted[2].role, "tool");
|
||||||
|
assert_eq!(converted[2].tool_call_id, Some("call_1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_choice_text_only() {
|
||||||
|
let choice = OpenAiChoice {
|
||||||
|
message: OpenAiResponseMessage {
|
||||||
|
content: Some("Hello!".to_string()),
|
||||||
|
tool_calls: None,
|
||||||
|
},
|
||||||
|
finish_reason: Some("stop".to_string()),
|
||||||
|
};
|
||||||
|
let (content, tool_calls) = extract_choice_content(&choice);
|
||||||
|
assert_eq!(content, Some("Hello!".to_string()));
|
||||||
|
assert!(tool_calls.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_choice_with_tool_calls() {
|
||||||
|
let choice = OpenAiChoice {
|
||||||
|
message: OpenAiResponseMessage {
|
||||||
|
content: Some("Let me search.".to_string()),
|
||||||
|
tool_calls: Some(vec![OpenAiResponseToolCall {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
function: OpenAiResponseFunction {
|
||||||
|
name: "search".to_string(),
|
||||||
|
arguments: r#"{"q":"test"}"#.to_string(),
|
||||||
|
},
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
finish_reason: Some("tool_calls".to_string()),
|
||||||
|
};
|
||||||
|
let (content, tool_calls) = extract_choice_content(&choice);
|
||||||
|
assert_eq!(content, Some("Let me search.".to_string()));
|
||||||
|
assert_eq!(tool_calls.len(), 1);
|
||||||
|
assert_eq!(tool_calls[0].name, "search");
|
||||||
|
assert_eq!(tool_calls[0].arguments["q"], "test");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// ─── Risk: hardcoded VS Code Copilot identity ───────────────────────────────
|
||||||
|
//
|
||||||
|
// The client ID and editor identity headers below are extracted from the
|
||||||
|
// VS Code Copilot Chat extension. This is the *only* publicly documented
|
||||||
|
// way to access the Copilot completions API with a personal GitHub token.
|
||||||
|
//
|
||||||
|
// **Known risks:**
|
||||||
|
// • GitHub may rotate or revoke this client ID at any time, which would
|
||||||
|
// break authentication for all IronClaw users until the constant is
|
||||||
|
// updated and a new release is shipped.
|
||||||
|
// • Using another product's client ID may violate GitHub's Terms of
|
||||||
|
// Service. Maintainers should seek explicit guidance from GitHub
|
||||||
|
// before shipping this to a wide audience.
|
||||||
|
// • The editor version strings (`vscode/1.99.3`, `copilot-chat/0.26.7`)
|
||||||
|
// will become stale and could eventually be rejected by the API.
|
||||||
|
//
|
||||||
|
// **Mitigation:** If GitHub publishes an official Copilot API client ID or
|
||||||
|
// an OAuth app registration flow for third-party tools, migrate to it
|
||||||
|
// immediately.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
pub const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
|
||||||
|
pub const GITHUB_COPILOT_SCOPE: &str = "read:user";
|
||||||
|
pub const GITHUB_COPILOT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||||
|
pub const GITHUB_COPILOT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||||
|
pub const GITHUB_COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||||
|
pub const GITHUB_COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
|
||||||
|
pub const GITHUB_COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
|
||||||
|
pub const GITHUB_COPILOT_EDITOR_VERSION: &str = "vscode/1.99.3";
|
||||||
|
pub const GITHUB_COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
|
||||||
|
pub const GITHUB_COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||||
|
|
||||||
|
/// Buffer before token expiry to trigger a refresh (5 minutes).
|
||||||
|
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DeviceCodeResponse {
|
||||||
|
pub device_code: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
#[serde(default = "default_poll_interval_secs")]
|
||||||
|
pub interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct AccessTokenResponse {
|
||||||
|
access_token: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GithubCopilotAuthError {
|
||||||
|
#[error("failed to start device login: {0}")]
|
||||||
|
DeviceCodeRequest(String),
|
||||||
|
#[error("failed to poll device login: {0}")]
|
||||||
|
TokenPolling(String),
|
||||||
|
#[error("device login was denied")]
|
||||||
|
AccessDenied,
|
||||||
|
#[error("device login expired before authorization completed")]
|
||||||
|
Expired,
|
||||||
|
#[error("github copilot token validation failed: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum DevicePollingStatus {
|
||||||
|
Pending,
|
||||||
|
SlowDown,
|
||||||
|
Authorized(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_headers() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"User-Agent".to_string(),
|
||||||
|
GITHUB_COPILOT_USER_AGENT.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Editor-Version".to_string(),
|
||||||
|
GITHUB_COPILOT_EDITOR_VERSION.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Editor-Plugin-Version".to_string(),
|
||||||
|
GITHUB_COPILOT_EDITOR_PLUGIN_VERSION.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Copilot-Integration-Id".to_string(),
|
||||||
|
GITHUB_COPILOT_INTEGRATION_ID.to_string(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_poll_interval_secs() -> u64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request_device_code(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
) -> Result<DeviceCodeResponse, GithubCopilotAuthError> {
|
||||||
|
let response = client
|
||||||
|
.post(GITHUB_COPILOT_DEVICE_CODE_URL)
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
|
||||||
|
.form(&[
|
||||||
|
("client_id", GITHUB_COPILOT_CLIENT_ID),
|
||||||
|
("scope", GITHUB_COPILOT_SCOPE),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
is_timeout = e.is_timeout(),
|
||||||
|
is_connect = e.is_connect(),
|
||||||
|
url = %GITHUB_COPILOT_DEVICE_CODE_URL,
|
||||||
|
"Copilot: device code request failed"
|
||||||
|
);
|
||||||
|
GithubCopilotAuthError::DeviceCodeRequest(format_reqwest_error(&e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
status = %status,
|
||||||
|
body = %truncate_for_error(&body),
|
||||||
|
"Copilot: device code endpoint returned error"
|
||||||
|
);
|
||||||
|
return Err(GithubCopilotAuthError::DeviceCodeRequest(format!(
|
||||||
|
"HTTP {status}: {}",
|
||||||
|
truncate_for_error(&body)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = response
|
||||||
|
.json::<DeviceCodeResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GithubCopilotAuthError::DeviceCodeRequest(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll_for_access_token(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
device_code: &str,
|
||||||
|
) -> Result<DevicePollingStatus, GithubCopilotAuthError> {
|
||||||
|
let response = client
|
||||||
|
.post(GITHUB_COPILOT_ACCESS_TOKEN_URL)
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
|
||||||
|
.form(&[
|
||||||
|
("client_id", GITHUB_COPILOT_CLIENT_ID),
|
||||||
|
("device_code", device_code),
|
||||||
|
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
is_timeout = e.is_timeout(),
|
||||||
|
is_connect = e.is_connect(),
|
||||||
|
url = %GITHUB_COPILOT_ACCESS_TOKEN_URL,
|
||||||
|
"Copilot: poll request failed"
|
||||||
|
);
|
||||||
|
GithubCopilotAuthError::TokenPolling(format_reqwest_error(&e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
status = %status,
|
||||||
|
body = %truncate_for_error(&body),
|
||||||
|
"Copilot: poll endpoint returned error"
|
||||||
|
);
|
||||||
|
return Err(GithubCopilotAuthError::TokenPolling(format!(
|
||||||
|
"HTTP {status}: {}",
|
||||||
|
truncate_for_error(&body)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.json::<AccessTokenResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| GithubCopilotAuthError::TokenPolling(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(token) = body.access_token {
|
||||||
|
return Ok(DevicePollingStatus::Authorized(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
match body.error.as_deref() {
|
||||||
|
Some("authorization_pending") | None => Ok(DevicePollingStatus::Pending),
|
||||||
|
Some("slow_down") => {
|
||||||
|
tracing::debug!("Copilot: GitHub requested slow_down, increasing poll interval");
|
||||||
|
Ok(DevicePollingStatus::SlowDown)
|
||||||
|
}
|
||||||
|
Some("access_denied") => {
|
||||||
|
tracing::warn!("Copilot: device login was denied by user");
|
||||||
|
Err(GithubCopilotAuthError::AccessDenied)
|
||||||
|
}
|
||||||
|
Some("expired_token") => {
|
||||||
|
tracing::warn!("Copilot: device code expired before authorization");
|
||||||
|
Err(GithubCopilotAuthError::Expired)
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
let desc = body
|
||||||
|
.error_description
|
||||||
|
.filter(|description| !description.is_empty())
|
||||||
|
.unwrap_or_else(|| other.to_string());
|
||||||
|
tracing::warn!(error = %other, description = %desc, "Copilot: unexpected poll error");
|
||||||
|
Err(GithubCopilotAuthError::TokenPolling(desc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum consecutive transient poll failures before giving up.
|
||||||
|
const MAX_POLL_FAILURES: u32 = 5;
|
||||||
|
|
||||||
|
pub async fn wait_for_device_login(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
device: &DeviceCodeResponse,
|
||||||
|
) -> Result<String, GithubCopilotAuthError> {
|
||||||
|
let expires_at = std::time::Instant::now()
|
||||||
|
.checked_add(Duration::from_secs(device.expires_in))
|
||||||
|
.ok_or(GithubCopilotAuthError::Expired)?;
|
||||||
|
let mut poll_interval = device.interval.max(1);
|
||||||
|
let mut consecutive_failures: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if std::time::Instant::now() >= expires_at {
|
||||||
|
tracing::warn!("Copilot: device login expired");
|
||||||
|
return Err(GithubCopilotAuthError::Expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(poll_interval)).await;
|
||||||
|
|
||||||
|
match poll_for_access_token(client, &device.device_code).await {
|
||||||
|
Ok(DevicePollingStatus::Pending) => {
|
||||||
|
consecutive_failures = 0;
|
||||||
|
}
|
||||||
|
Ok(DevicePollingStatus::SlowDown) => {
|
||||||
|
consecutive_failures = 0;
|
||||||
|
poll_interval = poll_interval.saturating_add(5);
|
||||||
|
}
|
||||||
|
Ok(DevicePollingStatus::Authorized(token)) => {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
// Definitive failures — propagate immediately
|
||||||
|
Err(GithubCopilotAuthError::AccessDenied) => {
|
||||||
|
return Err(GithubCopilotAuthError::AccessDenied);
|
||||||
|
}
|
||||||
|
Err(GithubCopilotAuthError::Expired) => {
|
||||||
|
return Err(GithubCopilotAuthError::Expired);
|
||||||
|
}
|
||||||
|
// Transient failures — retry with backoff
|
||||||
|
Err(e) => {
|
||||||
|
consecutive_failures += 1;
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
attempt = consecutive_failures,
|
||||||
|
max = MAX_POLL_FAILURES,
|
||||||
|
"Copilot: transient poll failure, will retry"
|
||||||
|
);
|
||||||
|
if consecutive_failures >= MAX_POLL_FAILURES {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"Copilot: too many consecutive poll failures, giving up"
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// Back off on transient errors
|
||||||
|
poll_interval = (poll_interval + 2).min(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a GitHub OAuth token by performing the Copilot token exchange.
|
||||||
|
///
|
||||||
|
/// This exchanges the raw OAuth token for a Copilot session token (proving the
|
||||||
|
/// token is valid and the user has Copilot access), then verifies the session
|
||||||
|
/// token works against the models endpoint.
|
||||||
|
pub async fn validate_token(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<(), GithubCopilotAuthError> {
|
||||||
|
// Step 1: Exchange the OAuth token for a Copilot session token.
|
||||||
|
// This validates both that the OAuth token is valid and that the user
|
||||||
|
// has an active Copilot subscription.
|
||||||
|
let session = exchange_copilot_token(client, token).await?;
|
||||||
|
// Step 2: Verify the session token works against the models endpoint.
|
||||||
|
let mut request = client
|
||||||
|
.get(GITHUB_COPILOT_MODELS_URL)
|
||||||
|
.bearer_auth(&session.token)
|
||||||
|
.timeout(Duration::from_secs(15));
|
||||||
|
|
||||||
|
for (key, value) in default_headers() {
|
||||||
|
request = request.header(&key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
is_timeout = e.is_timeout(),
|
||||||
|
is_connect = e.is_connect(),
|
||||||
|
"Copilot: models endpoint request failed"
|
||||||
|
);
|
||||||
|
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
status = %status,
|
||||||
|
body = %truncate_for_error(&body),
|
||||||
|
"Copilot: models endpoint returned error during validation"
|
||||||
|
);
|
||||||
|
Err(GithubCopilotAuthError::Validation(format!(
|
||||||
|
"HTTP {status}: {}",
|
||||||
|
truncate_for_error(&body)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the Copilot token exchange endpoint.
|
||||||
|
///
|
||||||
|
/// The `token` field is an HMAC-signed session token (not a JWT) used as
|
||||||
|
/// `Authorization: Bearer <token>` for requests to `api.githubcopilot.com`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CopilotTokenResponse {
|
||||||
|
/// The Copilot session token (HMAC-signed, not a JWT).
|
||||||
|
pub token: String,
|
||||||
|
/// Unix timestamp (seconds) when this token expires.
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange a GitHub OAuth token for a Copilot API session token.
|
||||||
|
///
|
||||||
|
/// Calls `GET https://api.github.com/copilot_internal/v2/token` with the
|
||||||
|
/// GitHub OAuth token in `Authorization: token <oauth_token>` format.
|
||||||
|
/// Returns a short-lived session token for `api.githubcopilot.com`.
|
||||||
|
pub async fn exchange_copilot_token(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
oauth_token: &str,
|
||||||
|
) -> Result<CopilotTokenResponse, GithubCopilotAuthError> {
|
||||||
|
let token_trimmed = oauth_token.trim();
|
||||||
|
let mut request = client
|
||||||
|
.get(GITHUB_COPILOT_TOKEN_URL)
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
|
// GitHub Copilot uses `token` auth scheme, not `Bearer`
|
||||||
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("token {token_trimmed}"),
|
||||||
|
)
|
||||||
|
.timeout(Duration::from_secs(15));
|
||||||
|
|
||||||
|
for (key, value) in default_headers() {
|
||||||
|
request = request.header(&key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
is_timeout = e.is_timeout(),
|
||||||
|
is_connect = e.is_connect(),
|
||||||
|
"Copilot: token exchange HTTP request failed"
|
||||||
|
);
|
||||||
|
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
status = %status,
|
||||||
|
body = %truncate_for_error(&body),
|
||||||
|
"Copilot: token exchange endpoint returned error"
|
||||||
|
);
|
||||||
|
return Err(GithubCopilotAuthError::Validation(format!(
|
||||||
|
"Copilot token exchange failed: HTTP {status}: {}",
|
||||||
|
truncate_for_error(&body)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_response = response.json::<CopilotTokenResponse>().await.map_err(|e| {
|
||||||
|
tracing::warn!(error = %e, "Copilot: failed to parse token exchange response");
|
||||||
|
GithubCopilotAuthError::Validation(e.to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(token_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages a cached Copilot API session token with automatic refresh.
|
||||||
|
///
|
||||||
|
/// The GitHub Copilot API requires a two-step authentication:
|
||||||
|
/// 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
|
||||||
|
/// 2. A short-lived Copilot session token (exchanged via `/copilot_internal/v2/token`)
|
||||||
|
///
|
||||||
|
/// This manager caches the session token and refreshes it automatically
|
||||||
|
/// before it expires (with a 5-minute buffer).
|
||||||
|
pub struct CopilotTokenManager {
|
||||||
|
client: reqwest::Client,
|
||||||
|
oauth_token: SecretString,
|
||||||
|
cached: RwLock<Option<CachedCopilotToken>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CachedCopilotToken {
|
||||||
|
token: SecretString,
|
||||||
|
expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CopilotTokenManager {
|
||||||
|
/// Create a new token manager with the given GitHub OAuth token.
|
||||||
|
pub fn new(client: reqwest::Client, oauth_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
oauth_token: SecretString::from(oauth_token),
|
||||||
|
cached: RwLock::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a valid Copilot session token, refreshing if needed.
|
||||||
|
///
|
||||||
|
/// Returns the cached token if it has more than 5 minutes remaining,
|
||||||
|
/// otherwise exchanges the OAuth token for a fresh session token.
|
||||||
|
pub async fn get_token(&self) -> Result<SecretString, GithubCopilotAuthError> {
|
||||||
|
// Fast path: check if cached token is still valid under read lock.
|
||||||
|
{
|
||||||
|
let guard = self.cached.read().await;
|
||||||
|
if let Some(ref cached) = *guard {
|
||||||
|
let now = unix_now();
|
||||||
|
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
|
||||||
|
return Ok(cached.token.clone());
|
||||||
|
}
|
||||||
|
tracing::debug!(
|
||||||
|
expires_at = cached.expires_at,
|
||||||
|
now = now,
|
||||||
|
"Copilot: cached session token expired or expiring soon, refreshing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: acquire write lock and re-check (another caller may have
|
||||||
|
// already refreshed while we waited for the lock).
|
||||||
|
let mut guard = self.cached.write().await;
|
||||||
|
if let Some(ref cached) = *guard {
|
||||||
|
let now = unix_now();
|
||||||
|
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
|
||||||
|
return Ok(cached.token.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response =
|
||||||
|
exchange_copilot_token(&self.client, self.oauth_token.expose_secret()).await?;
|
||||||
|
let token = SecretString::from(response.token);
|
||||||
|
|
||||||
|
let expires_at = response.expires_at;
|
||||||
|
*guard = Some(CachedCopilotToken {
|
||||||
|
token: token.clone(),
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::debug!(expires_at = expires_at, "Copilot session token refreshed");
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate the cached session token.
|
||||||
|
///
|
||||||
|
/// Called when the API returns 401, so the next `get_token()` call
|
||||||
|
/// will perform a fresh token exchange instead of reusing the stale token.
|
||||||
|
pub async fn invalidate(&self) {
|
||||||
|
let mut guard = self.cached.write().await;
|
||||||
|
*guard = None;
|
||||||
|
tracing::debug!("Copilot session token invalidated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_for_error(body: &str) -> String {
|
||||||
|
const LIMIT: usize = 200;
|
||||||
|
if body.len() <= LIMIT {
|
||||||
|
return body.to_string();
|
||||||
|
}
|
||||||
|
let end = crate::util::floor_char_boundary(body, LIMIT);
|
||||||
|
format!("{}...", &body[..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a reqwest error with its full causal chain for debugging.
|
||||||
|
///
|
||||||
|
/// `reqwest::Error::to_string()` often just says "error sending request"
|
||||||
|
/// without the underlying cause (timeout, DNS, TLS, connection refused).
|
||||||
|
/// This walks the `source()` chain to surface the real problem.
|
||||||
|
fn format_reqwest_error(e: &reqwest::Error) -> String {
|
||||||
|
use std::error::Error;
|
||||||
|
let mut msg = e.to_string();
|
||||||
|
let mut source = e.source();
|
||||||
|
while let Some(cause) = source {
|
||||||
|
msg.push_str(&format!(": {cause}"));
|
||||||
|
source = cause.source();
|
||||||
|
}
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_headers_include_required_identity_headers() {
|
||||||
|
let headers = default_headers();
|
||||||
|
assert!(headers.iter().any(|(key, value)| {
|
||||||
|
key == "Copilot-Integration-Id" && value == GITHUB_COPILOT_INTEGRATION_ID
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key == "Editor-Version"
|
||||||
|
&& value == GITHUB_COPILOT_EDITOR_VERSION)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key == "User-Agent" && value == GITHUB_COPILOT_USER_AGENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_for_error_preserves_utf8_boundaries() {
|
||||||
|
let long = "日本語".repeat(100);
|
||||||
|
let truncated = truncate_for_error(&long);
|
||||||
|
assert!(truncated.ends_with("..."));
|
||||||
|
assert!(truncated.is_char_boundary(truncated.len() - 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_for_error_short_strings_unchanged() {
|
||||||
|
let short = "hello";
|
||||||
|
assert_eq!(truncate_for_error(short), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- poll_for_access_token response parsing ---
|
||||||
|
|
||||||
|
fn parse_access_token_body(json: &str) -> AccessTokenResponse {
|
||||||
|
serde_json::from_str(json).expect("valid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_authorization_pending_response() {
|
||||||
|
let body: AccessTokenResponse =
|
||||||
|
parse_access_token_body(r#"{"error": "authorization_pending"}"#);
|
||||||
|
assert!(body.access_token.is_none());
|
||||||
|
assert_eq!(body.error.as_deref(), Some("authorization_pending"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_slow_down_response() {
|
||||||
|
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "slow_down"}"#);
|
||||||
|
assert_eq!(body.error.as_deref(), Some("slow_down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_access_denied_response() {
|
||||||
|
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "access_denied"}"#);
|
||||||
|
assert_eq!(body.error.as_deref(), Some("access_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_expired_token_response() {
|
||||||
|
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "expired_token"}"#);
|
||||||
|
assert_eq!(body.error.as_deref(), Some("expired_token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_successful_token_response() {
|
||||||
|
let body: AccessTokenResponse =
|
||||||
|
parse_access_token_body(r#"{"access_token": "ghu_abc123"}"#);
|
||||||
|
assert_eq!(body.access_token.as_deref(), Some("ghu_abc123"));
|
||||||
|
assert!(body.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_error_with_description() {
|
||||||
|
let body: AccessTokenResponse = parse_access_token_body(
|
||||||
|
r#"{"error": "bad_verification_code", "error_description": "The code has expired"}"#,
|
||||||
|
);
|
||||||
|
assert_eq!(body.error.as_deref(), Some("bad_verification_code"));
|
||||||
|
assert_eq!(
|
||||||
|
body.error_description.as_deref(),
|
||||||
|
Some("The code has expired")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_device_code_response_with_defaults() {
|
||||||
|
let json = r#"{
|
||||||
|
"device_code": "dc_123",
|
||||||
|
"user_code": "ABCD-1234",
|
||||||
|
"verification_uri": "https://github.com/login/device",
|
||||||
|
"expires_in": 900
|
||||||
|
}"#;
|
||||||
|
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
|
||||||
|
assert_eq!(resp.device_code, "dc_123");
|
||||||
|
assert_eq!(resp.user_code, "ABCD-1234");
|
||||||
|
assert_eq!(resp.interval, 5); // default_poll_interval_secs
|
||||||
|
assert_eq!(resp.expires_in, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_device_code_response_with_custom_interval() {
|
||||||
|
let json = r#"{
|
||||||
|
"device_code": "dc_456",
|
||||||
|
"user_code": "EFGH-5678",
|
||||||
|
"verification_uri": "https://github.com/login/device",
|
||||||
|
"expires_in": 600,
|
||||||
|
"interval": 10
|
||||||
|
}"#;
|
||||||
|
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
|
||||||
|
assert_eq!(resp.interval, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CopilotTokenManager ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_manager_caches_token_and_returns_same_value() {
|
||||||
|
// Pre-populate the cache with a token that expires far in the future.
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||||
|
|
||||||
|
let far_future = unix_now() + 3600;
|
||||||
|
{
|
||||||
|
let mut guard = manager.cached.write().await;
|
||||||
|
*guard = Some(CachedCopilotToken {
|
||||||
|
token: SecretString::from("cached_session_token".to_string()),
|
||||||
|
expires_at: far_future,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = manager.get_token().await.expect("should return cached");
|
||||||
|
assert_eq!(token.expose_secret(), "cached_session_token");
|
||||||
|
|
||||||
|
// A second call should return the same cached token.
|
||||||
|
let token2 = manager.get_token().await.expect("should return cached");
|
||||||
|
assert_eq!(token2.expose_secret(), "cached_session_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_manager_invalidation_clears_cache() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||||
|
|
||||||
|
let far_future = unix_now() + 3600;
|
||||||
|
{
|
||||||
|
let mut guard = manager.cached.write().await;
|
||||||
|
*guard = Some(CachedCopilotToken {
|
||||||
|
token: SecretString::from("old_token".to_string()),
|
||||||
|
expires_at: far_future,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.invalidate().await;
|
||||||
|
|
||||||
|
let guard = manager.cached.read().await;
|
||||||
|
assert!(guard.is_none(), "cache should be empty after invalidation");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_manager_expired_token_triggers_refresh_path() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||||
|
|
||||||
|
// Set a token that is already expired (expires_at in the past).
|
||||||
|
{
|
||||||
|
let mut guard = manager.cached.write().await;
|
||||||
|
*guard = Some(CachedCopilotToken {
|
||||||
|
token: SecretString::from("stale_token".to_string()),
|
||||||
|
expires_at: 1, // way in the past
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_token will try the slow path (token exchange) which will fail
|
||||||
|
// because we have no real server, but this proves the cached stale
|
||||||
|
// token is NOT returned.
|
||||||
|
let result = manager.get_token().await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"expired cached token should trigger exchange, which fails without a server"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_manager_within_buffer_triggers_refresh() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||||
|
|
||||||
|
// Set a token that expires within the refresh buffer window.
|
||||||
|
let expires_soon = unix_now() + TOKEN_REFRESH_BUFFER_SECS - 10;
|
||||||
|
{
|
||||||
|
let mut guard = manager.cached.write().await;
|
||||||
|
*guard = Some(CachedCopilotToken {
|
||||||
|
token: SecretString::from("expiring_soon".to_string()),
|
||||||
|
expires_at: expires_soon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = manager.get_token().await;
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"token within buffer should trigger exchange"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CopilotTokenResponse parsing ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_copilot_token_response() {
|
||||||
|
let json = r#"{"token": "tid=abc;exp=999;sku=123;sig=xyz", "expires_at": 1700000000}"#;
|
||||||
|
let resp: CopilotTokenResponse = serde_json::from_str(json).expect("valid JSON");
|
||||||
|
assert!(resp.token.starts_with("tid="));
|
||||||
|
assert_eq!(resp.expires_at, 1700000000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ pub mod config;
|
|||||||
pub mod costs;
|
pub mod costs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
|
mod github_copilot;
|
||||||
|
pub(crate) mod github_copilot_auth;
|
||||||
mod nearai_chat;
|
mod nearai_chat;
|
||||||
pub mod oauth_helpers;
|
pub mod oauth_helpers;
|
||||||
pub mod openai_codex_provider;
|
pub mod openai_codex_provider;
|
||||||
@@ -171,6 +173,17 @@ fn create_registry_provider(
|
|||||||
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
||||||
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
||||||
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
||||||
|
ProviderProtocol::GithubCopilot => {
|
||||||
|
let provider =
|
||||||
|
github_copilot::GithubCopilotProvider::new(config, request_timeout_secs)?;
|
||||||
|
tracing::debug!(
|
||||||
|
provider = %config.provider_id,
|
||||||
|
model = %config.model,
|
||||||
|
base_url = %config.base_url,
|
||||||
|
"Using GitHub Copilot provider (token exchange)"
|
||||||
|
);
|
||||||
|
Ok(Arc::new(provider))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub enum ProviderProtocol {
|
|||||||
Anthropic,
|
Anthropic,
|
||||||
/// Ollama API (OpenAI-ish, no API key required).
|
/// Ollama API (OpenAI-ish, no API key required).
|
||||||
Ollama,
|
Ollama,
|
||||||
|
/// GitHub Copilot API (OpenAI-compatible with token exchange).
|
||||||
|
GithubCopilot,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the setup wizard should collect credentials for this provider.
|
/// How the setup wizard should collect credentials for this provider.
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ pub struct Settings {
|
|||||||
pub secrets_master_key_hex: Option<String>,
|
pub secrets_master_key_hex: Option<String>,
|
||||||
|
|
||||||
// === Step 3: Inference Provider ===
|
// === Step 3: Inference Provider ===
|
||||||
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible", "tinfoil", "bedrock".
|
/// LLM backend: "nearai", "anthropic", "openai", "github_copilot", "ollama", "openai_compatible", "tinfoil", "bedrock".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub llm_backend: Option<String>,
|
pub llm_backend: Option<String>,
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -218,6 +218,7 @@ env-var mode or skipped secrets.
|
|||||||
| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` |
|
| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` |
|
||||||
| Anthropic | API key | `llm_anthropic_api_key` | `ANTHROPIC_API_KEY` |
|
| Anthropic | API key | `llm_anthropic_api_key` | `ANTHROPIC_API_KEY` |
|
||||||
| OpenAI | API key | `llm_openai_api_key` | `OPENAI_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 | - | - |
|
| Ollama | None | - | - |
|
||||||
| OpenRouter | API key | `llm_openrouter_api_key` | `OPENROUTER_API_KEY` |
|
| OpenRouter | API key | `llm_openrouter_api_key` | `OPENROUTER_API_KEY` |
|
||||||
| OpenAI-compatible | Optional API key | `llm_compatible_api_key` | `LLM_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
|
5. Preserve `selected_model` on a same-backend re-run; clear it only when
|
||||||
switching to a different backend
|
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`):
|
**NEAR AI** (`setup_nearai`):
|
||||||
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
|
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
|
||||||
- Options 1-2 (GitHub/Google): browser OAuth → **NEAR AI Chat** mode
|
- 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
|
pub secrets_master_key_source: KeySource, // Keychain | Env | None
|
||||||
|
|
||||||
// Step 3: Inference
|
// 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 ollama_base_url: Option<String>,
|
||||||
pub openai_compatible_base_url: Option<String>,
|
pub openai_compatible_base_url: Option<String>,
|
||||||
|
|
||||||
|
|||||||
+129
-1
@@ -3,7 +3,7 @@
|
|||||||
//! The wizard guides users through:
|
//! The wizard guides users through:
|
||||||
//! 1. Database connection
|
//! 1. Database connection
|
||||||
//! 2. Security (secrets master key)
|
//! 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
|
//! 4. Model selection
|
||||||
//! 5. Embeddings
|
//! 5. Embeddings
|
||||||
//! 6. Channel configuration
|
//! 6. Channel configuration
|
||||||
@@ -1191,6 +1191,10 @@ impl SetupWizard {
|
|||||||
return self.setup_anthropic().await;
|
return self.setup_anthropic().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider_id == "github_copilot" {
|
||||||
|
return self.setup_github_copilot().await;
|
||||||
|
}
|
||||||
|
|
||||||
match setup {
|
match setup {
|
||||||
crate::llm::registry::SetupHint::ApiKey {
|
crate::llm::registry::SetupHint::ApiKey {
|
||||||
secret_name,
|
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.
|
/// Anthropic OAuth setup: extract token from `claude login` credentials.
|
||||||
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
|
async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> {
|
||||||
self.set_llm_backend_preserving_model("anthropic");
|
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]
|
#[test]
|
||||||
fn test_is_openai_chat_model_includes_gpt5_and_filters_non_chat_variants() {
|
fn test_is_openai_chat_model_includes_gpt5_and_filters_non_chat_variants() {
|
||||||
assert!(is_openai_chat_model("gpt-5"));
|
assert!(is_openai_chat_model("gpt-5"));
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fn bootstrap_env_round_trips_llm_backend() {
|
|||||||
for backend in &[
|
for backend in &[
|
||||||
"nearai",
|
"nearai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
|
"github_copilot",
|
||||||
"ollama",
|
"ollama",
|
||||||
"openai_compatible",
|
"openai_compatible",
|
||||||
"tinfoil",
|
"tinfoil",
|
||||||
|
|||||||
Reference in New Issue
Block a user