From fa417a347d44a8ba56969aa0a80917b8b6efc408 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 8 Jul 2026 23:55:38 +0200 Subject: [PATCH] harness-providers: Copilot device-flow, token exchange, provider (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline-verifiable building blocks for GitHub Copilot; network paths are the flagged live-verification risk and are covered by pure unit tests only. - copilot/device_flow.rs: OAuth device flow (RFC 8628). No hardcoded client id — ai-harness must register its own GitHub OAuth app and pass it in. Pure parse_poll_response (pending/slow_down/success/failed) + slow_down interval bump; request_device_code/poll_once/poll_for_token (injected sleep). - copilot/token.rs: copilot_internal/v2/token exchange with cache + single-flight refresh (tokio::Mutex) and direct-Bearer fallback on 401/403/404; needs_refresh honors a 120s skew and expires_at==0 = never. - copilot/provider.rs: CopilotProvider over api.githubcopilot.com, routing each model to chat/responses/anthropic codec by its /models supported_endpoints (parse_models + codec_for_endpoints); headers (X-GitHub-Api-Version, Openai-Intent, x-initiator, Copilot-Vision-Request, anthropic-beta for anthropic models). - 11 unit tests (device-flow parsing, token refresh math, codec routing, /models parsing). Deferred (needs live API + TUI work): EngineHandle::login device-flow modal, startup /models fetch, auth.json-backed registry wiring. Co-Authored-By: Claude Opus 4.8 --- .../src/copilot/device_flow.rs | 209 +++++++++++++ crates/harness-providers/src/copilot/mod.rs | 10 + .../harness-providers/src/copilot/provider.rs | 296 ++++++++++++++++++ crates/harness-providers/src/copilot/token.rs | 144 +++++++++ crates/harness-providers/src/lib.rs | 2 + 5 files changed, 661 insertions(+) create mode 100644 crates/harness-providers/src/copilot/device_flow.rs create mode 100644 crates/harness-providers/src/copilot/mod.rs create mode 100644 crates/harness-providers/src/copilot/provider.rs create mode 100644 crates/harness-providers/src/copilot/token.rs diff --git a/crates/harness-providers/src/copilot/device_flow.rs b/crates/harness-providers/src/copilot/device_flow.rs new file mode 100644 index 0000000..406c58e --- /dev/null +++ b/crates/harness-providers/src/copilot/device_flow.rs @@ -0,0 +1,209 @@ +//! GitHub OAuth device flow (RFC 8628) for Copilot login. +//! +//! ai-harness must register its own GitHub OAuth app and supply its client id (we do not +//! hardcode opencode's). The client id is passed in by the caller — see [`request_device_code`]. + +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; +const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; +/// Minimum scope needed to call the Copilot token-exchange endpoint. +pub const SCOPE: &str = "read:user"; + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// Seconds between polls; GitHub requires honoring this and any `slow_down` bumps. + #[serde(default = "default_interval")] + pub interval: u64, + #[serde(default)] + pub expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Result of one poll of the access-token endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PollOutcome { + /// The user hasn't authorized yet; keep polling at the current interval. + Pending, + /// GitHub asked us to slow down; add 5s to the interval (RFC 8628). + SlowDown, + /// Authorization complete. + Success { access_token: String }, + /// Terminal failure (expired code, denied, unknown error) with a human-readable reason. + Failed(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum DeviceFlowError { + #[error("network: {0}")] + Network(String), + #[error("unexpected response: {0}")] + Unexpected(String), +} + +/// Pure mapping of an access-token poll response body to a [`PollOutcome`], so the state +/// machine is testable without a live GitHub. +pub fn parse_poll_response(body: &Value) -> PollOutcome { + if let Some(token) = body["access_token"].as_str() { + return PollOutcome::Success { + access_token: token.to_string(), + }; + } + match body["error"].as_str() { + Some("authorization_pending") => PollOutcome::Pending, + Some("slow_down") => PollOutcome::SlowDown, + Some(other) => { + let desc = body["error_description"].as_str().unwrap_or(other); + PollOutcome::Failed(desc.to_string()) + } + None => PollOutcome::Failed("no access_token and no error in response".to_string()), + } +} + +/// Applies a `slow_down` to a poll interval per RFC 8628 (+5s). +pub fn bump_interval(interval: u64) -> u64 { + interval + 5 +} + +/// Step 1: request a device + user code for `client_id`. +pub async fn request_device_code( + client: &reqwest::Client, + client_id: &str, +) -> Result { + let resp = client + .post(DEVICE_CODE_URL) + .header("accept", "application/json") + .json(&serde_json::json!({"client_id": client_id, "scope": SCOPE})) + .send() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + let value: Value = resp + .json() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + serde_json::from_value(value.clone()) + .map_err(|_| DeviceFlowError::Unexpected(value.to_string())) +} + +/// Step 2 (single poll): exchange the device code for an access token, once. +pub async fn poll_once( + client: &reqwest::Client, + client_id: &str, + device_code: &str, +) -> Result { + let resp = client + .post(ACCESS_TOKEN_URL) + .header("accept", "application/json") + .json(&serde_json::json!({ + "client_id": client_id, + "device_code": device_code, + "grant_type": GRANT_TYPE, + })) + .send() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + let value: Value = resp + .json() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + Ok(parse_poll_response(&value)) +} + +/// Step 2 (full loop): polls until success or terminal failure, honoring `interval` and +/// `slow_down`. `sleep` is injected so tests can drive it without real time. +pub async fn poll_for_token( + client: &reqwest::Client, + client_id: &str, + device: &DeviceCode, + sleep: S, +) -> Result +where + S: Fn(Duration) -> Fut, + Fut: std::future::Future, +{ + let mut interval = device.interval; + loop { + sleep(Duration::from_secs(interval)).await; + match poll_once(client, client_id, &device.device_code).await? { + PollOutcome::Pending => {} + PollOutcome::SlowDown => interval = bump_interval(interval), + PollOutcome::Success { access_token } => return Ok(access_token), + PollOutcome::Failed(reason) => return Err(DeviceFlowError::Unexpected(reason)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_success() { + let outcome = + parse_poll_response(&json!({"access_token": "gho_abc", "token_type": "bearer"})); + assert_eq!( + outcome, + PollOutcome::Success { + access_token: "gho_abc".into() + } + ); + } + + #[test] + fn parses_pending_and_slow_down() { + assert_eq!( + parse_poll_response(&json!({"error": "authorization_pending"})), + PollOutcome::Pending + ); + assert_eq!( + parse_poll_response(&json!({"error": "slow_down", "interval": 10})), + PollOutcome::SlowDown + ); + } + + #[test] + fn parses_terminal_errors_with_description() { + match parse_poll_response( + &json!({"error": "expired_token", "error_description": "code expired"}), + ) { + PollOutcome::Failed(msg) => assert_eq!(msg, "code expired"), + other => panic!("expected Failed, got {other:?}"), + } + assert!(matches!( + parse_poll_response(&json!({"error": "access_denied"})), + PollOutcome::Failed(_) + )); + assert!(matches!( + parse_poll_response(&json!({})), + PollOutcome::Failed(_) + )); + } + + #[test] + fn slow_down_adds_five_seconds() { + assert_eq!(bump_interval(5), 10); + assert_eq!(bump_interval(10), 15); + } + + #[test] + fn device_code_defaults_interval() { + let dc: DeviceCode = serde_json::from_value(json!({ + "device_code": "d", + "user_code": "WXYZ-1234", + "verification_uri": "https://github.com/login/device" + })) + .unwrap(); + assert_eq!(dc.interval, 5); + } +} diff --git a/crates/harness-providers/src/copilot/mod.rs b/crates/harness-providers/src/copilot/mod.rs new file mode 100644 index 0000000..7416c0a --- /dev/null +++ b/crates/harness-providers/src/copilot/mod.rs @@ -0,0 +1,10 @@ +//! GitHub Copilot: OAuth device-flow login, token exchange, and a provider that multiplexes +//! the chat/responses/anthropic codecs behind `api.githubcopilot.com`. + +pub mod device_flow; +pub mod provider; +pub mod token; + +pub use device_flow::{DeviceCode, PollOutcome}; +pub use provider::{CopilotCodec, CopilotModel, CopilotProvider}; +pub use token::{CopilotToken, TokenProvider}; diff --git a/crates/harness-providers/src/copilot/provider.rs b/crates/harness-providers/src/copilot/provider.rs new file mode 100644 index 0000000..f2e754b --- /dev/null +++ b/crates/harness-providers/src/copilot/provider.rs @@ -0,0 +1,296 @@ +//! Copilot provider: multiplexes all three wire codecs behind `api.githubcopilot.com`. +//! +//! Each model's `supported_endpoints` (from `GET /models`) decides which codec/endpoint to +//! use. The token comes from [`super::token::TokenProvider`]. Network paths here are the +//! flagged live-verification risk — the routing/header logic is unit-tested; end-to-end +//! streaming must be checked against the live API. + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use harness_core::llm::{ + Initiator, LlmEventStream, LlmRequest, Provider, ProviderError, WireContent, +}; +use harness_core::types::ModelInfo; +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +use crate::codec::{anthropic, openai_chat, openai_responses}; + +use super::token::TokenProvider; + +pub const DEFAULT_BASE_URL: &str = "https://api.githubcopilot.com"; +const API_VERSION: &str = "2026-06-01"; +const ANTHROPIC_BETA: &str = "interleaved-thinking-2025-05-14"; +const USER_AGENT: &str = concat!("ai-harness/", env!("CARGO_PKG_VERSION")); + +/// Which wire codec a Copilot model speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopilotCodec { + Chat, + Responses, + Anthropic, +} + +impl CopilotCodec { + fn path(self) -> &'static str { + match self { + CopilotCodec::Chat => "/chat/completions", + CopilotCodec::Responses => "/responses", + CopilotCodec::Anthropic => "/v1/messages", + } + } + + fn build_request(self, req: &LlmRequest) -> Value { + match self { + CopilotCodec::Chat => openai_chat::build_request(req), + CopilotCodec::Responses => openai_responses::build_request(req), + CopilotCodec::Anthropic => anthropic::build_request(req), + } + } + + fn decode( + self, + stream: impl futures::Stream> + Send + 'static, + ) -> LlmEventStream { + match self { + CopilotCodec::Chat => openai_chat::decode(stream), + CopilotCodec::Responses => openai_responses::decode(stream), + CopilotCodec::Anthropic => anthropic::decode(stream), + } + } +} + +/// A single model as advertised by `GET /models`. +#[derive(Debug, Clone)] +pub struct CopilotModel { + pub id: String, + pub codec: CopilotCodec, + /// Only `model_picker_enabled` models are offered in the UI picker. + pub picker_enabled: bool, +} + +/// Picks a codec from a model's `supported_endpoints`, preferring the responses API, then the +/// Anthropic messages API, then chat completions. +pub fn codec_for_endpoints(endpoints: &[String]) -> CopilotCodec { + let has = |needle: &str| endpoints.iter().any(|e| e.contains(needle)); + if has("/responses") { + CopilotCodec::Responses + } else if has("/v1/messages") || has("/messages") { + CopilotCodec::Anthropic + } else { + CopilotCodec::Chat + } +} + +/// Parses a Copilot `GET /models` response body. +pub fn parse_models(body: &Value) -> Vec { + body["data"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m["id"].as_str()?.to_string(); + let endpoints: Vec = m["supported_endpoints"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|e| e.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + Some(CopilotModel { + id, + codec: codec_for_endpoints(&endpoints), + picker_enabled: m["model_picker_enabled"].as_bool().unwrap_or(false), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn has_images(req: &LlmRequest) -> bool { + req.messages + .iter() + .flat_map(|m| &m.content) + .any(|c| matches!(c, WireContent::Image { .. })) +} + +fn initiator_header(initiator: Initiator) -> &'static str { + match initiator { + Initiator::User => "user", + Initiator::Agent => "agent", + } +} + +pub struct CopilotProvider { + tokens: TokenProvider, + base_url: String, + client: reqwest::Client, + /// model id → codec, from `GET /models`. Unknown models default to chat completions. + codecs: HashMap, +} + +impl CopilotProvider { + pub fn new(oauth_token: impl Into, models: Vec) -> Self { + Self::with_base_url(oauth_token, DEFAULT_BASE_URL, models) + } + + pub fn with_base_url( + oauth_token: impl Into, + base_url: impl Into, + models: Vec, + ) -> Self { + let client = reqwest::Client::new(); + let codecs = models.into_iter().map(|m| (m.id, m.codec)).collect(); + Self { + tokens: TokenProvider::new(oauth_token, client.clone()), + base_url: base_url.into(), + client, + codecs, + } + } + + fn codec_for(&self, model: &str) -> CopilotCodec { + self.codecs + .get(model) + .copied() + .unwrap_or(CopilotCodec::Chat) + } + + fn classify_error(status: reqwest::StatusCode, body: String) -> ProviderError { + match status.as_u16() { + 401 | 403 => ProviderError::Auth(body), + 429 => ProviderError::RateLimited { retry_after: None }, + s if (500..600).contains(&s) => ProviderError::Overloaded, + s => ProviderError::Http { status: s, body }, + } + } +} + +#[async_trait] +impl Provider for CopilotProvider { + fn id(&self) -> &str { + "github-copilot" + } + + async fn list_models(&self) -> Result, ProviderError> { + // Cost/limit metadata is layered on by models.dev; routing metadata lives in `codecs`. + Ok(Vec::new()) + } + + async fn stream( + &self, + req: LlmRequest, + cancel: CancellationToken, + ) -> Result { + let codec = self.codec_for(&req.model); + let body = codec.build_request(&req); + let url = format!("{}{}", self.base_url, codec.path()); + + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let token = self.tokens.token(now_secs).await; + + let mut builder = self + .client + .post(&url) + .header("authorization", format!("Bearer {}", token.token)) + .header("user-agent", USER_AGENT) + .header("x-github-api-version", API_VERSION) + .header("openai-intent", "conversation-edits") + .header("x-initiator", initiator_header(req.initiator)); + if has_images(&req) { + builder = builder.header("copilot-vision-request", "true"); + } + if codec == CopilotCodec::Anthropic { + builder = builder.header("anthropic-beta", ANTHROPIC_BETA); + } + + let send = builder.json(&body).send(); + let response = tokio::select! { + result = send => result.map_err(|e| ProviderError::Network(e.to_string()))?, + _ = cancel.cancelled() => return Err(ProviderError::Cancelled), + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(Self::classify_error(status, body)); + } + + Ok(codec.decode(response.bytes_stream())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn codec_routing_prefers_responses_then_anthropic_then_chat() { + assert_eq!( + codec_for_endpoints(&["/chat/completions".into(), "/responses".into()]), + CopilotCodec::Responses + ); + assert_eq!( + codec_for_endpoints(&["/v1/messages".into()]), + CopilotCodec::Anthropic + ); + assert_eq!( + codec_for_endpoints(&["/chat/completions".into()]), + CopilotCodec::Chat + ); + assert_eq!(codec_for_endpoints(&[]), CopilotCodec::Chat); + } + + #[test] + fn parses_models_with_endpoints_and_picker_flag() { + let body = json!({ + "data": [ + {"id": "gpt-4o", "supported_endpoints": ["/chat/completions"], "model_picker_enabled": true}, + {"id": "claude-sonnet-4-5", "supported_endpoints": ["/v1/messages"], "model_picker_enabled": true}, + {"id": "o3", "supported_endpoints": ["/responses"], "model_picker_enabled": false}, + {"id": "no-endpoints"} + ] + }); + let models = parse_models(&body); + assert_eq!(models.len(), 4); + assert_eq!(models[0].codec, CopilotCodec::Chat); + assert!(models[0].picker_enabled); + assert_eq!(models[1].codec, CopilotCodec::Anthropic); + assert_eq!(models[2].codec, CopilotCodec::Responses); + assert!(!models[2].picker_enabled); + // Missing supported_endpoints → default chat, picker false. + assert_eq!(models[3].codec, CopilotCodec::Chat); + assert!(!models[3].picker_enabled); + } + + #[test] + fn unknown_model_defaults_to_chat_codec() { + let provider = CopilotProvider::new("oauth", vec![]); + assert_eq!(provider.codec_for("whatever"), CopilotCodec::Chat); + assert_eq!(provider.id(), "github-copilot"); + } + + #[test] + fn known_model_uses_mapped_codec() { + let provider = CopilotProvider::new( + "oauth", + vec![CopilotModel { + id: "claude-sonnet-4-5".into(), + codec: CopilotCodec::Anthropic, + picker_enabled: true, + }], + ); + assert_eq!( + provider.codec_for("claude-sonnet-4-5"), + CopilotCodec::Anthropic + ); + } +} diff --git a/crates/harness-providers/src/copilot/token.rs b/crates/harness-providers/src/copilot/token.rs new file mode 100644 index 0000000..e4165a7 --- /dev/null +++ b/crates/harness-providers/src/copilot/token.rs @@ -0,0 +1,144 @@ +//! Copilot API token strategy (flagged-risk area — verify against the live API). +//! +//! opencode sends the GitHub OAuth token directly as the Bearer (`expires: 0`). The classic +//! Copilot API instead wants a short-lived token from `copilot_internal/v2/token`. We try the +//! exchange first and cache it until shortly before expiry; if the endpoint rejects us, we fall +//! back to the direct-Bearer behavior. Refresh is single-flight under a `tokio::Mutex`. + +use serde::Deserialize; +use tokio::sync::Mutex; + +const EXCHANGE_URL: &str = "https://api.github.com/copilot_internal/v2/token"; +/// Refresh this many seconds before the reported expiry. +const EXPIRY_SKEW_SECS: i64 = 120; +const USER_AGENT: &str = concat!("ai-harness/", env!("CARGO_PKG_VERSION")); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopilotToken { + pub token: String, + /// Unix seconds; `0` means "never expires" (direct-Bearer fallback). + pub expires_at: i64, +} + +impl CopilotToken { + /// True when the token should be refreshed at `now_secs` (never, for `expires_at == 0`). + pub fn needs_refresh(&self, now_secs: i64) -> bool { + self.expires_at != 0 && now_secs + EXPIRY_SKEW_SECS >= self.expires_at + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TokenError { + #[error("network: {0}")] + Network(String), + /// The exchange endpoint is unavailable/unauthorized — the caller should fall back to the + /// direct-Bearer strategy. + #[error("exchange unsupported (status {0})")] + ExchangeUnsupported(u16), + #[error("unexpected response: {0}")] + Unexpected(String), +} + +#[derive(Deserialize)] +struct ExchangeResponse { + token: String, + #[serde(default)] + expires_at: i64, +} + +/// Performs the token exchange once. `ExchangeUnsupported` signals the caller to fall back. +pub async fn exchange( + client: &reqwest::Client, + oauth_token: &str, +) -> Result { + let resp = client + .get(EXCHANGE_URL) + .header("authorization", format!("token {oauth_token}")) + .header("accept", "application/json") + .header("user-agent", USER_AGENT) + .send() + .await + .map_err(|e| TokenError::Network(e.to_string()))?; + + let status = resp.status(); + if !status.is_success() { + // 401/403/404 → this deployment doesn't support the exchange; fall back to direct Bearer. + if matches!(status.as_u16(), 401 | 403 | 404) { + return Err(TokenError::ExchangeUnsupported(status.as_u16())); + } + return Err(TokenError::Network(format!("status {}", status.as_u16()))); + } + + let parsed: ExchangeResponse = resp + .json() + .await + .map_err(|e| TokenError::Unexpected(e.to_string()))?; + Ok(CopilotToken { + token: parsed.token, + expires_at: parsed.expires_at, + }) +} + +/// Caches the exchanged Copilot token and refreshes it single-flight. Falls back to using the +/// OAuth token directly (never-expiring) when the exchange endpoint rejects the request. +pub struct TokenProvider { + oauth_token: String, + client: reqwest::Client, + cached: Mutex>, +} + +impl TokenProvider { + pub fn new(oauth_token: impl Into, client: reqwest::Client) -> Self { + Self { + oauth_token: oauth_token.into(), + client, + cached: Mutex::new(None), + } + } + + /// Returns a usable Bearer token, refreshing/exchanging as needed. `now_secs` is injected + /// for testability. + pub async fn token(&self, now_secs: i64) -> CopilotToken { + let mut guard = self.cached.lock().await; + if let Some(tok) = guard.as_ref() { + if !tok.needs_refresh(now_secs) { + return tok.clone(); + } + } + let fresh = match exchange(&self.client, &self.oauth_token).await { + Ok(tok) => tok, + Err(_) => CopilotToken { + // Direct-Bearer fallback: use the OAuth token itself, never expiring. + token: self.oauth_token.clone(), + expires_at: 0, + }, + }; + *guard = Some(fresh.clone()); + fresh + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn never_expiring_token_does_not_refresh() { + let tok = CopilotToken { + token: "t".into(), + expires_at: 0, + }; + assert!(!tok.needs_refresh(i64::MAX)); + } + + #[test] + fn refresh_triggers_within_skew_window() { + let tok = CopilotToken { + token: "t".into(), + expires_at: 1_000, + }; + assert!(!tok.needs_refresh(800)); // 800 + 120 < 1000 + assert!(tok.needs_refresh(881)); // 881 + 120 >= 1000 + assert!(tok.needs_refresh(1_000)); + } +} diff --git a/crates/harness-providers/src/lib.rs b/crates/harness-providers/src/lib.rs index 5f08c31..d7f8fb0 100644 --- a/crates/harness-providers/src/lib.rs +++ b/crates/harness-providers/src/lib.rs @@ -1,12 +1,14 @@ pub mod anthropic; pub mod auth; pub mod codec; +pub mod copilot; pub mod modelsdev; pub mod openai; pub mod registry; pub use anthropic::AnthropicProvider; pub use auth::{AuthRecord, AuthStorage}; +pub use copilot::CopilotProvider; pub use modelsdev::ModelCatalog; pub use openai::OpenAiProvider; pub use registry::ProviderRegistry;