From 81794382789615eb9989d1ab3fb1e5ce27089776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Jul 2026 04:21:02 -0400 Subject: [PATCH] feat(providers): add GitHub Copilot subscription OAuth (device flow + copilot-token re-mint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28th platform `github-copilot` (uses_oauth, ChatCompletions wire). Two-stage auth: RFC-8628 GitHub device flow (client Iv1.b507a08c87ecfe98, scope read:user, errors in a 200 body) mints the DURABLE github token; a GET api.github.com/copilot_internal/ v2/token exchange re-mints the SHORT-LIVED copilot token. Persisted as key=copilot token, refresh_token=github token, expires_at=copilot expiry; the "refresh" is a copilot-token re-mint (not a refresh_token grant), dispatched via OAuthTokenBody::GithubCopilotExchange in the generic refresher. VS Code editor-identity headers on /models + /chat/completions, gated on SamplerConfig.github_copilot / PlatformId::sends_copilot_editor_headers() so every other ChatCompletions provider stays byte-identical. Live /models filtered (parse_github_copilot_listing) to the openai-completions-served models: keep iff model_picker_enabled && policy.state!="disabled" && tool_calls!=false AND not a claude-4.x/5.x (messages) or gpt-5/oswe/mai- (responses-only) id — those need per-model wire routing (documented debt), excluded rather than mis-routed. Inherits the leak-safe pooled routing (scope oauth/github-copilot); its bearer/ refresh/api_key never touch the Kimi token (regression test added). Fail-fast on an out-of-range copilot expires_at (would otherwise silently 401 mid-session). Adversarial security review: GO, no CRITICAL/HIGH. Known limitation: Pi's per-model policy-enablement POST is not ported (documented in AGENTS.md). --- AGENTS.md | 36 +- crates/codegen/kigi-models/src/lib.rs | 336 +++++++++++- .../codegen/kigi-sampler/src/actor/state.rs | 1 + crates/codegen/kigi-sampler/src/client.rs | 100 +++- crates/codegen/kigi-sampler/src/config.rs | 9 + .../codegen/kigi-sampler/tests/test_actor.rs | 1 + .../codegen/kigi-sampling-types/src/types.rs | 22 + .../kigi-shell/src/agent/auth_method.rs | 9 + crates/codegen/kigi-shell/src/agent/config.rs | 9 + .../kigi-shell/src/agent/models_fetch.rs | 170 ++++++ .../kigi-shell/src/agent/subagent/mod.rs | 6 + .../kigi-shell/src/auth/device_code.rs | 144 +++++- crates/codegen/kigi-shell/src/auth/flow.rs | 8 + .../kigi-shell/src/auth/github_copilot.rs | 485 ++++++++++++++++++ crates/codegen/kigi-shell/src/auth/mod.rs | 1 + .../kigi-shell/src/auth/oauth_registry.rs | 37 ++ .../src/auth/refresh/generic_refresher.rs | 12 +- .../session/acp_session_impl/sampler_turn.rs | 12 + .../auth_error_no_retry_tests.rs | 1 + .../cancel_running_task_tests.rs | 4 + .../src/session/helpers/session_compact.rs | 1 + .../src/test_support/lsp_runtime.rs | 1 + crates/codegen/kigi-shell/tests/common/mod.rs | 1 + crates/codegen/kigi-tui/src/app/app_view.rs | 66 +-- .../codegen/kigi-tui/src/views/welcome/mod.rs | 4 + 25 files changed, 1432 insertions(+), 44 deletions(-) create mode 100644 crates/codegen/kigi-shell/src/auth/github_copilot.rs diff --git a/AGENTS.md b/AGENTS.md index 5d2d3cd..72562f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,7 +175,8 @@ edges stay deterministic Rust. The harness appends a terminal scope-keyed `AuthManager::new_oauth_provider` + `refresh::GenericDeviceRefresher` (selected by `build_refresher` via `oauth_config_for_scope_key`; the refresher dispatches the refresh body by - `token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`). Kimi + `token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`, + `GithubCopilotExchange` → `auth::github_copilot` copilot-token re-mint). Kimi Code keeps `oauth: None` and its bespoke path unchanged. The interactive login is dispatched by `OAuthConfig.flow` (in `run_oauth_provider_flow`): - `OAuthFlow::DeviceCode` → `auth::oauth_device` (RFC-8628 device-code, plain @@ -192,9 +193,36 @@ edges stay deterministic Rust. The harness appends a terminal prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its `/v1/models` listing rides the same Bearer + oauth-beta headers. - Both are INTERACTIVE login rows advertised right after `kimi-code` - (`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok` then - `claude-pro-max`). The catalog fetch resolves each such platform's OWN session + - `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE). + Provider: `github-copilot` (`scope_key oauth/github-copilot`, base + `api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an + RFC-8628 device flow on `github.com` (client `Iv1.b507a08c87ecfe98`, scope + `read:user`) whose errors ride a `200` body (not `4xx`) — it mints the + DURABLE github token. Stage 2 (`GET api.github.com/copilot_internal/v2/token` + with `copilot_exchange` + editor headers) re-mints the SHORT-LIVED copilot + token. Persisted as `KimiAuth.key = copilot token`, `refresh_token = github + token`, `expires_at = copilot expiry`; the "refresh" is a copilot-token + RE-MINT (GET, not a `refresh_token` grant). Every `/models` listing AND + `/chat/completions` request carries the VS Code editor-identity headers + (`User-Agent GitHubCopilotChat/…`, `Editor-Version`, `Editor-Plugin-Version`, + `Copilot-Integration-Id`; `+X-GitHub-Api-Version` on `/models`, `+X-Initiator + user` on inference) — gated on `SamplerConfig.github_copilot` / + `PlatformId::sends_copilot_editor_headers()` so every other ChatCompletions + provider stays byte-identical. WIRE-COMPAT SCOPE: Kigi is one-wire-per- + platform, so the catalog is FILTERED (`parse_github_copilot_listing`) to the + openai-completions-served models — keep iff `model_picker_enabled` && + `policy.state != "disabled"` && `tool_calls != false` AND the id is NOT a + `claude-(haiku|sonnet|opus)-[45]` (anthropic-messages) or `gpt-5/oswe/mai-` + (responses-only) model. Those excluded models need per-model wire routing + (deferred, documented debt), NOT included lest they fail at inference. + KNOWN LIMITATION: Kigi does NOT port Pi's per-model policy-acceptance step + (`POST {base}/models/{id}/policy {state:"enabled"}`). A kept model whose + Copilot policy is unconfigured can list yet `403` at inference until the user + enables it once in GitHub's UI — a deliberate omission (it mutates account + state and is unverifiable without a live Copilot account), not a silent gap. + These are INTERACTIVE login rows advertised right after `kimi-code` + (`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok`, + `claude-pro-max`, `github-copilot`). The catalog fetch resolves each such platform's OWN session token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes `platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone → `proxy_url()`). Tokens/codes/verifiers are NEVER logged. diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index a1a7344..0f0a884 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -109,6 +109,13 @@ pub enum OAuthFlow { /// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port` /// loopback listener, then is exchanged at `token_host`+`token_path`. PkceLocalhost { redirect_port: u16 }, + /// GitHub Copilot two-stage flow (github-copilot): an RFC-8628 device flow + /// on `auth_host` (github.com) mints the DURABLE GitHub token, which is then + /// exchanged at `copilot_exchange` for the SHORT-LIVED copilot session + /// token. The github token is persisted as `refresh_token`; the copilot + /// token as `key`. GitHub's poll returns errors in a `200` body (not `4xx`), + /// so it drives a Copilot-specific poll, not the generic device wire. + GithubDeviceCopilot, } /// Body encoding a provider's token endpoint expects for the code-exchange and @@ -120,6 +127,11 @@ pub enum OAuthTokenBody { Form, /// `application/json` (Claude PKCE wire). Json, + /// GitHub Copilot copilot-token re-mint (github-copilot): "refresh" is NOT + /// a `refresh_token` grant — it is a `GET copilot_internal/v2/token` bearing + /// the durable GitHub token (`refresh_token` field) + editor headers, which + /// re-mints the short-lived copilot token. Dispatched to the Copilot wire. + GithubCopilotExchange, } /// Generic OAuth configuration carried by a `uses_oauth` platform whose login @@ -159,6 +171,13 @@ pub struct OAuthConfig { pub flow: OAuthFlow, /// Body encoding the token endpoint expects (form vs JSON). pub token_body: OAuthTokenBody, + /// Second-stage token-exchange endpoint `(host, path)` for the GitHub + /// Copilot two-stage flow (github-copilot): `("https://api.github.com", + /// "/copilot_internal/v2/token")`. The durable GitHub token is exchanged + /// here for the short-lived copilot token, at BOTH login and every re-mint + /// "refresh". `None` for the standard flows (xai-grok, claude-pro-max), + /// whose refresh is a plain `refresh_token` grant against `token_host`. + pub copilot_exchange: Option<(&'static str, &'static str)>, } /// xAI / Grok subscription device-code OAuth (ported from Pi @@ -174,6 +193,7 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig { extra_device_field: Some(("referrer", "kigi")), flow: OAuthFlow::DeviceCode, token_body: OAuthTokenBody::Form, + copilot_exchange: None, }; /// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape @@ -197,6 +217,32 @@ pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig { redirect_port: 53692, }, token_body: OAuthTokenBody::Json, + copilot_exchange: None, +}; + +/// Base-URL override for the GitHub Copilot inference/listing channel +/// (dev/test escape hatch). Production defaults to the individual-subscription +/// endpoint `https://api.individual.githubcopilot.com`. +pub const COPILOT_BASE_URL_ENV: &str = "KIGI_COPILOT_BASE_URL"; + +/// GitHub Copilot subscription OAuth (two-stage device flow → copilot-token +/// exchange). Authoritative constants from Pi `earendil-works/pi` +/// `auth/oauth/github-copilot.ts`: the VS Code Copilot Chat public client id, +/// the github.com device endpoints, and the `api.github.com/copilot_internal/ +/// v2/token` copilot-token exchange (Stage 2). The device flow mints the +/// durable GitHub token; the exchange re-mints the short-lived copilot token. +pub const COPILOT_OAUTH_CONFIG: OAuthConfig = OAuthConfig { + client_id: "Iv1.b507a08c87ecfe98", + auth_host: "https://github.com", + device_path: "/login/device/code", + token_host: "https://github.com", + token_path: "/login/oauth/access_token", + scope: "read:user", + scope_key: "oauth/github-copilot", + extra_device_field: None, + flow: OAuthFlow::GithubDeviceCopilot, + token_body: OAuthTokenBody::GithubCopilotExchange, + copilot_exchange: Some(("https://api.github.com", "/copilot_internal/v2/token")), }; /// The generic device-code OAuth config for a platform, or `None` for API-key @@ -1101,6 +1147,43 @@ const CLAUDE_PRO_MAX_SPEC: PlatformSpec = PlatformSpec { strip_listing_id_prefix: None, }; +const GITHUB_COPILOT_SPEC: PlatformSpec = PlatformSpec { + id: "github-copilot", + display_name: "GitHub Copilot", + base_url: BaseUrlSource::EnvOr { + env: COPILOT_BASE_URL_ENV, + default: "https://api.individual.githubcopilot.com", + }, + uses_oauth: true, + oauth: Some(&COPILOT_OAUTH_CONFIG), + allowed_model_prefixes: None, + // OAuth channel: no API key envs (the copilot session is the bearer). + api_key_envs: &[], + vendor: "GitHub", + console_host: Some("github.com"), + login_label: Some("GitHub Copilot (subscription)"), + models_dev_id: Some("github-copilot"), + // Copilot /models serves availability flags (model_picker_enabled/policy/ + // tool_calls), NOT context/thinking metadata — enrich from models.dev. + wire_serves_metadata: false, + // ChatCompletions ONLY: the catalog is filtered (see + // `parse_github_copilot_listing`) to the openai-completions-served models. + // The claude-4.x/5.x (messages) and gpt-5/oswe/mai- (responses-only) models + // this endpoint also lists are EXCLUDED — Kigi is one-wire-per-platform and + // per-model wire routing is deferred (documented limitation). + wire_api: PlatformWireApi::ChatCompletions, + // OpenAI-shape listing endpoint, but with Copilot-specific availability + // fields — parsed by `parse_github_copilot_listing`, gated on the platform. + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + // The copilot listing filter + the wire-compat filter govern the catalog, + // NOT the enrichment membership (a live but enrichment-lagging model stays). + restrict_to_enriched: false, + key_validation_path: None, + strip_listing_id_prefix: None, +}; + /// The platform registry. Platforms are compiled-in spec rows; there is no /// dynamic provider registration (PRD F2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -1160,12 +1243,15 @@ pub enum PlatformId { /// Claude Pro/Max subscription via PKCE-localhost OAuth (Anthropic Messages /// wire reached with an OAuth bearer instead of an API key). ClaudeProMax, + /// GitHub Copilot subscription via the two-stage device-code OAuth flow + /// (ChatCompletions wire reached with the short-lived copilot token). + GithubCopilot, } impl PlatformId { /// All platforms, in catalog precedence order: the subscription channel /// first so "default model = first list item" favors it when present. - pub const ALL: [PlatformId; 27] = [ + pub const ALL: [PlatformId; 28] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -1193,6 +1279,7 @@ impl PlatformId { Self::MinimaxCn, Self::XaiGrok, Self::ClaudeProMax, + Self::GithubCopilot, ]; /// The registry row backing this platform (single source of per-platform @@ -1226,6 +1313,7 @@ impl PlatformId { Self::MinimaxCn => &MINIMAX_CN_SPEC, Self::XaiGrok => &XAI_GROK_SPEC, Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC, + Self::GithubCopilot => &GITHUB_COPILOT_SPEC, } } @@ -1352,6 +1440,16 @@ impl PlatformId { pub fn chat_compat(self) -> PlatformChatCompat { self.spec().chat_compat } + + /// True ONLY for `github-copilot`: its `/models` listing and every + /// `/chat/completions` inference request must carry the VS Code Copilot + /// editor-identity headers (User-Agent / Editor-Version / + /// Editor-Plugin-Version / Copilot-Integration-Id). The shell gates the + /// listing headers and the sampler gates the inference headers on this, so + /// every other ChatCompletions platform's request stays byte-identical. + pub fn sends_copilot_editor_headers(self) -> bool { + matches!(self, Self::GithubCopilot) + } } /// Split a managed catalog key `{platform_id}/{model_id}` back into its @@ -1675,6 +1773,128 @@ pub fn filter_allowed_models(platform: PlatformId, models: Vec) -> Ve .collect() } +// ── GitHub Copilot listing adapter (github-copilot) ───────────────────────── + +/// A Copilot model id that routes to the anthropic-messages wire in Pi +/// (`/^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/`) — EXCLUDED from Kigi's +/// ChatCompletions catalog. Matches `claude-{haiku,sonnet,opus}-` followed by a +/// single `4`/`5` and then `.`, `-`, or end (so `claude-fable-5` is NOT a match +/// and stays served; `claude-sonnet-45` is not a real family and does not match). +fn is_copilot_messages_claude(id: &str) -> bool { + for family in ["claude-haiku-", "claude-sonnet-", "claude-opus-"] { + let Some(rest) = id.strip_prefix(family) else { + continue; + }; + let mut chars = rest.chars(); + if matches!(chars.next(), Some('4') | Some('5')) + && matches!(chars.next(), None | Some('.') | Some('-')) + { + return true; + } + } + false +} + +/// A Copilot model id served ONLY through the `/responses` endpoint in Pi +/// (`gpt-5*` / `oswe*` / `mai-*`) — EXCLUDED from Kigi's ChatCompletions catalog. +fn is_copilot_responses_only(id: &str) -> bool { + id.starts_with("gpt-5") || id.starts_with("oswe") || id.starts_with("mai-") +} + +/// Whether a Copilot model id is served by the openai-completions wire — the +/// ONLY wire Kigi's github-copilot platform speaks. Excludes the claude-4.x/5.x +/// (messages) and gpt-5/oswe/mai- (responses-only) ids that would fail at +/// inference on `/chat/completions` (documented per-model-routing limitation). +pub fn is_copilot_completions_served(id: &str) -> bool { + !is_copilot_messages_claude(id) && !is_copilot_responses_only(id) +} + +#[derive(serde::Deserialize)] +struct CopilotListing { + /// No default: a 200 body without `data` is a contract violation and must + /// error like the OpenAI-shape branch, not yield an empty catalog. + data: Vec, +} + +#[derive(serde::Deserialize)] +struct CopilotListingModel { + id: String, + #[serde(default)] + name: Option, + #[serde(default)] + model_picker_enabled: bool, + #[serde(default)] + policy: Option, + #[serde(default)] + capabilities: Option, +} + +#[derive(serde::Deserialize)] +struct CopilotPolicy { + #[serde(default)] + state: Option, +} + +#[derive(serde::Deserialize)] +struct CopilotCapabilities { + #[serde(default)] + supports: Option, +} + +#[derive(serde::Deserialize)] +struct CopilotSupports { + /// Absent means "not declined" → allowed (Pi: `supports.tool_calls !== + /// false`). Only an explicit `false` drops the model. + #[serde(default)] + tool_calls: Option, +} + +/// Parse the GitHub Copilot `GET {base}/models` response and apply BOTH filters +/// (a Copilot quirk, gated on the platform): +/// 1. availability — keep iff `model_picker_enabled == true` AND +/// `policy.state != "disabled"` AND `capabilities.supports.tool_calls != +/// false` (Pi `isSelectableCopilotModel`); +/// 2. wire-compat — keep iff the id is openai-completions-served (drops the +/// claude-4.x/5.x messages models and the gpt-5/oswe/mai- responses-only +/// models, which Kigi's single-wire platform cannot route). +/// +/// Metadata (context window, thinking) is NOT served here — enrichment from +/// models.dev "github-copilot" fills it downstream. +pub fn parse_github_copilot_listing(json: &str) -> Result, serde_json::Error> { + let listing: CopilotListing = serde_json::from_str(json)?; + let kept = listing + .data + .into_iter() + .filter(|m| { + let picker_enabled = m.model_picker_enabled; + let policy_ok = m + .policy + .as_ref() + .and_then(|p| p.state.as_deref()) + .is_none_or(|state| state != "disabled"); + let tool_calls_ok = m + .capabilities + .as_ref() + .and_then(|c| c.supports.as_ref()) + .and_then(|s| s.tool_calls) + != Some(false); + picker_enabled && policy_ok && tool_calls_ok && is_copilot_completions_served(&m.id) + }) + .map(|m| WireModel { + id: m.id, + context_length: 0, + supports_reasoning: false, + supports_image_in: false, + supports_video_in: false, + display_name: m.name, + max_output_tokens: 0, + supports_thinking_type: None, + think_efforts: None, + }) + .collect(); + Ok(kept) +} + // ── Bundled offline fallback catalog ──────────────────────────────────────── /// The raw JSON, embedded at compile time. OFFLINE LAST RESORT: consulted only @@ -2097,6 +2317,117 @@ mod tests { assert_eq!(c.base_url(), "https://mock.claude/v1"); } + /// github-copilot is the two-stage device-code OAuth platform: it carries a + /// `GithubDeviceCopilot`/`GithubCopilotExchange` `OAuthConfig` with a + /// copilot-token exchange endpoint (Stage 2), speaks the ChatCompletions + /// wire with a Bearer copilot token + the editor-headers gate, enriches from + /// models.dev "github-copilot", and keys its models under `github-copilot/`. + #[test] + fn github_copilot_is_a_two_stage_oauth_platform() { + let g = PlatformId::GithubCopilot; + assert_eq!(g.as_str(), "github-copilot"); + assert!(g.uses_oauth()); + assert!( + g.sends_copilot_editor_headers(), + "github-copilot must gate the editor-identity headers" + ); + // Every OTHER platform must NOT send the editor headers (regression). + for other in PlatformId::ALL { + if other != PlatformId::GithubCopilot { + assert!( + !other.sends_copilot_editor_headers(), + "{} must not send the copilot editor headers", + other.as_str() + ); + } + } + let cfg = g + .oauth() + .expect("github-copilot carries a two-stage OAuthConfig"); + assert_eq!(cfg, &COPILOT_OAUTH_CONFIG); + assert_eq!(cfg.client_id, "Iv1.b507a08c87ecfe98"); + assert_eq!(cfg.auth_host, "https://github.com"); + assert_eq!(cfg.device_path, "/login/device/code"); + assert_eq!(cfg.token_path, "/login/oauth/access_token"); + assert_eq!(cfg.scope, "read:user"); + assert_eq!(cfg.scope_key, "oauth/github-copilot"); + assert_eq!(cfg.flow, OAuthFlow::GithubDeviceCopilot); + assert_eq!(cfg.token_body, OAuthTokenBody::GithubCopilotExchange); + assert_eq!( + cfg.copilot_exchange, + Some(("https://api.github.com", "/copilot_internal/v2/token")), + "the Stage-2 copilot-token exchange endpoint must be configured" + ); + // xai/claude carry no copilot exchange (their refresh is a plain grant). + assert_eq!(XAI_OAUTH_CONFIG.copilot_exchange, None); + assert_eq!(CLAUDE_OAUTH_CONFIG.copilot_exchange, None); + // Scope-key lookup resolves the config (drives the generic refresher's + // Copilot re-mint dispatch). + assert_eq!( + oauth_config_for_scope_key("oauth/github-copilot"), + Some(&COPILOT_OAUTH_CONFIG) + ); + // ChatCompletions wire, Bearer, models.dev "github-copilot", own base. + assert_eq!(g.models_dev_id(), Some("github-copilot")); + assert_eq!(g.wire_api(), PlatformWireApi::ChatCompletions); + assert_eq!(g.listing(), ListingDialect::OpenAi); + assert_eq!(g.key_header(), PlatformKeyHeader::Bearer); + assert_eq!(g.api_key_env_names(), &[] as &[&str]); + assert!(!g.restrict_to_enriched()); + assert_eq!(g.managed_model_key("gpt-4.1"), "github-copilot/gpt-4.1"); + let _guard = kigi_env::EnvVarGuard::set(COPILOT_BASE_URL_ENV, "https://mock.copilot"); + assert_eq!(g.base_url(), "https://mock.copilot"); + } + + /// The Copilot `/models` filter keeps ONLY openai-completions-served, + /// selectable, tool-calling models — dropping the claude-4.x/5.x (messages) + /// and gpt-5/oswe/mai- (responses-only) ids AND the disabled / picker-off / + /// tool-call-declining ones — with the display name carried through. + #[test] + fn copilot_listing_filters_by_availability_and_wire_compat() { + let body = serde_json::json!({ "data": [ + // KEPT: completions-served, picker on, no policy, tool_calls absent. + { "id": "gpt-4.1", "name": "GPT-4.1", "model_picker_enabled": true }, + // KEPT: policy enabled + tool_calls true. + { "id": "gemini-3-flash-preview", "model_picker_enabled": true, + "policy": {"state": "enabled"}, + "capabilities": {"supports": {"tool_calls": true}} }, + // KEPT: claude-fable-5 is NOT a messages-routed claude family. + { "id": "claude-fable-5", "model_picker_enabled": true }, + // DROPPED: claude-4.x → anthropic-messages wire. + { "id": "claude-opus-4-8", "model_picker_enabled": true }, + // DROPPED: claude-5.x → anthropic-messages wire. + { "id": "claude-sonnet-5", "model_picker_enabled": true }, + // DROPPED: gpt-5* → responses-only. + { "id": "gpt-5.2", "model_picker_enabled": true }, + // DROPPED: mai-* → responses-only. + { "id": "mai-code-1", "model_picker_enabled": true }, + // DROPPED: picker disabled. + { "id": "gpt-4o", "model_picker_enabled": false }, + // DROPPED: policy disabled. + { "id": "kimi-k2.7-code", "model_picker_enabled": true, + "policy": {"state": "disabled"} }, + // DROPPED: tool_calls explicitly false. + { "id": "text-embed", "model_picker_enabled": true, + "capabilities": {"supports": {"tool_calls": false}} } + ]}) + .to_string(); + let kept = parse_github_copilot_listing(&body).expect("valid listing"); + let ids: Vec<&str> = kept.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + vec!["gpt-4.1", "gemini-3-flash-preview", "claude-fable-5"], + "only completions-served, selectable, tool-calling models survive" + ); + assert_eq!( + kept[0].display_name.as_deref(), + Some("GPT-4.1"), + "the wire display name must carry through" + ); + // A 200 body without `data` is a contract violation (never empty catalog). + assert!(parse_github_copilot_listing("{}").is_err()); + } + /// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain /// literal) but is silently unparseable and excluded from model sync. /// The exhaustive match below fails compilation when a variant is added, @@ -2132,9 +2463,10 @@ mod tests { PlatformId::MinimaxCn => 24, PlatformId::XaiGrok => 25, PlatformId::ClaudeProMax => 26, + PlatformId::GithubCopilot => 27, } } - const VARIANT_COUNT: usize = 27; // update together with `ordinal` + const VARIANT_COUNT: usize = 28; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-sampler/src/actor/state.rs b/crates/codegen/kigi-sampler/src/actor/state.rs index 57021c9..3e5df59 100644 --- a/crates/codegen/kigi-sampler/src/actor/state.rs +++ b/crates/codegen/kigi-sampler/src/actor/state.rs @@ -90,6 +90,7 @@ mod tests { api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, diff --git a/crates/codegen/kigi-sampler/src/client.rs b/crates/codegen/kigi-sampler/src/client.rs index f050e8c..5211605 100644 --- a/crates/codegen/kigi-sampler/src/client.rs +++ b/crates/codegen/kigi-sampler/src/client.rs @@ -454,6 +454,31 @@ impl SamplingClient { ); } + // GitHub Copilot editor-identity headers (github-copilot only). Copilot's + // proxy validates the VS Code editor identity, so the ChatCompletions + // request MUST carry it. `User-Agent` is set in the UA block below (it + // would otherwise be overwritten); here we add the other three editor + // headers plus `X-Initiator: user`. Gated on `github_copilot` so every + // other ChatCompletions provider (groq, …) stays byte-identical. + if config.github_copilot { + headers.insert( + HeaderName::from_static("editor-version"), + HeaderValue::from_static(kigi_sampling_types::COPILOT_EDITOR_VERSION), + ); + headers.insert( + HeaderName::from_static("editor-plugin-version"), + HeaderValue::from_static(kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION), + ); + headers.insert( + HeaderName::from_static("copilot-integration-id"), + HeaderValue::from_static(kigi_sampling_types::COPILOT_INTEGRATION_ID), + ); + headers.insert( + HeaderName::from_static("x-initiator"), + HeaderValue::from_static(kigi_sampling_types::COPILOT_INITIATOR), + ); + } + // Apply all extra headers verbatim. This is the single // injection point for proxy-auth headers and any other URL- or // environment-specific headers the session decides to set. @@ -471,10 +496,13 @@ impl SamplingClient { // (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent // plus the OAuth device headers, src/kimi_cli/llm.py:317-323). { - // Claude Pro/Max OAuth path presents the claude-cli identity; - // every other path keeps the kigi User-Agent. + // Claude Pro/Max OAuth presents the claude-cli identity; GitHub + // Copilot presents the VS Code Copilot Chat identity; every other + // path keeps the kigi User-Agent. let ua_string = if config.anthropic_oauth { kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string() + } else if config.github_copilot { + kigi_sampling_types::COPILOT_USER_AGENT.to_string() } else { match config.origin_client.as_ref() { Some(origin) => user_agent_string_for(origin), @@ -1961,6 +1989,7 @@ mod tests { api_backend: ApiBackend::ChatCompletions, auth_scheme: AuthScheme::Bearer, anthropic_oauth: false, + github_copilot: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 8192, @@ -2085,6 +2114,73 @@ mod tests { ); } + /// GitHub Copilot ChatCompletions client (`github_copilot = true`) carries + /// the VS Code Copilot editor-identity headers + `X-Initiator: user` and + /// presents the Copilot User-Agent (overriding the kigi UA). + #[test] + fn github_copilot_client_sends_editor_identity_headers() { + let mut config = minimal_config(); + config.github_copilot = true; + let client = SamplingClient::new(config).expect("client builds"); + let h = &client.default_headers; + assert_eq!( + h.get(USER_AGENT).and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::COPILOT_USER_AGENT), + "Copilot presents the VS Code Copilot User-Agent" + ); + assert_eq!( + h.get("editor-version").and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::COPILOT_EDITOR_VERSION) + ); + assert_eq!( + h.get("editor-plugin-version").and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION) + ); + assert_eq!( + h.get("copilot-integration-id") + .and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::COPILOT_INTEGRATION_ID) + ); + assert_eq!( + h.get("x-initiator").and_then(|v| v.to_str().ok()), + Some("user"), + "inference carries X-Initiator: user" + ); + } + + /// REGRESSION: a plain ChatCompletions client (github-copilot OFF, standing + /// in for groq) carries NONE of the Copilot editor headers and keeps the + /// kigi User-Agent — every other ChatCompletions provider stays untouched. + #[test] + fn plain_chat_completions_client_has_no_copilot_editor_headers() { + // github_copilot stays false (as it is for groq and every other + // ChatCompletions platform). + let client = SamplingClient::new(minimal_config()).expect("client builds"); + let h = &client.default_headers; + assert!( + h.get("editor-version").is_none(), + "groq must NOT send Editor-Version" + ); + assert!( + h.get("editor-plugin-version").is_none(), + "groq must NOT send Editor-Plugin-Version" + ); + assert!( + h.get("copilot-integration-id").is_none(), + "groq must NOT send Copilot-Integration-Id" + ); + assert!( + h.get("x-initiator").is_none(), + "groq must NOT send X-Initiator" + ); + assert!( + h.get(USER_AGENT) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ua| ua.starts_with("kigi/")), + "groq keeps the kigi User-Agent" + ); + } + /// The system-prompt prefix is prepended as a distinct leading `text` /// block for each `system` shape (absent / string / blocks), preserving the /// caller's prompt, and is idempotent (not stamped twice). diff --git a/crates/codegen/kigi-sampler/src/config.rs b/crates/codegen/kigi-sampler/src/config.rs index af0dd5c..e1d26eb 100644 --- a/crates/codegen/kigi-sampler/src/config.rs +++ b/crates/codegen/kigi-sampler/src/config.rs @@ -62,6 +62,14 @@ pub struct SamplerConfig { /// API-key `anthropic` + `minimax` Messages requests stay byte-identical. #[serde(default)] pub anthropic_oauth: bool, + /// GitHub Copilot ChatCompletions adaptation (github-copilot only). When + /// true the request carries the VS Code Copilot editor-identity headers + /// (User-Agent `GitHubCopilotChat/…`, `Editor-Version`, + /// `Editor-Plugin-Version`, `Copilot-Integration-Id`) plus `X-Initiator: + /// user`. Gated so every other ChatCompletions provider (groq, …) stays + /// byte-identical. + #[serde(default)] + pub github_copilot: bool, /// Extra request headers applied verbatim. The sampler never inspects /// the URL to derive headers; callers (the session) inject proxy auth /// and other access headers here before constructing the config. @@ -152,6 +160,7 @@ impl Default for SamplerConfig { api_backend: ApiBackend::default(), auth_scheme: AuthScheme::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: IndexMap::new(), context_window: 0, force_http1: false, diff --git a/crates/codegen/kigi-sampler/tests/test_actor.rs b/crates/codegen/kigi-sampler/tests/test_actor.rs index 477a004..4bde302 100644 --- a/crates/codegen/kigi-sampler/tests/test_actor.rs +++ b/crates/codegen/kigi-sampler/tests/test_actor.rs @@ -79,6 +79,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig { api_backend: ApiBackend::ChatCompletions, auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, chat_compat: Default::default(), extra_headers: IndexMap::new(), context_window: 128_000, diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index c656591..7f2ca2c 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -1076,6 +1076,28 @@ pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75"; pub const CLAUDE_CODE_SYSTEM_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; +// ── GitHub Copilot editor-identity headers ────────────────────────────────── +// The VS Code Copilot Chat client identity. Copilot's proxy authorizes the +// short-lived copilot token AND validates these editor headers, so they ride +// the `copilot_internal/v2/token` exchange, the `/models` listing, and every +// `/chat/completions` inference request. github-copilot-GATED: no other +// platform sends them, so their requests stay byte-identical. Values are +// non-secret wire constants (ported from Pi `api/github-copilot-headers.ts` + +// `auth/oauth/github-copilot.ts`). + +/// `User-Agent` for the Copilot path (overrides the default kigi UA, OAuth-gated). +pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.35.0"; +/// `Editor-Version` — the host editor Copilot believes it is talking to. +pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0"; +/// `Editor-Plugin-Version` — the Copilot Chat plugin build. +pub const COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0"; +/// `Copilot-Integration-Id` — the integration the token is scoped to. +pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat"; +/// `X-GitHub-Api-Version` — sent ONLY on the `/models` listing. +pub const COPILOT_API_VERSION: &str = "2026-06-01"; +/// `X-Initiator` value — sent ONLY on inference (`user`, per the spec). +pub const COPILOT_INITIATOR: &str = "user"; + /// ChatCompletions request-body adaptation dialect. Providers disagree on /// how thinking rides an OpenAI-compatible body: Kimi wants /// `thinking:{type,effort}`, DeepSeek wants diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index f082a72..d750625 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -600,6 +600,11 @@ mod tests { ); assert_eq!( ids[kimi_pos + 3], + "github-copilot", + "github-copilot is the next interactive OAuth login, after claude-pro-max" + ); + assert_eq!( + ids[kimi_pos + 4], MOONSHOT_CN_METHOD_ID, "the api-key rows follow the generic oauth logins" ); @@ -750,6 +755,7 @@ mod tests { KIMI_CODE_METHOD_ID, "xai-grok", "claude-pro-max", + "github-copilot", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -800,6 +806,7 @@ mod tests { KIMI_CODE_METHOD_ID, "xai-grok", "claude-pro-max", + "github-copilot", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -843,6 +850,7 @@ mod tests { KIMI_CODE_METHOD_ID, "xai-grok", "claude-pro-max", + "github-copilot", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", @@ -889,6 +897,7 @@ mod tests { KIMI_CODE_METHOD_ID, "xai-grok", "claude-pro-max", + "github-copilot", MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, "openai", diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index c652245..cbba93f 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4091,6 +4091,14 @@ pub fn sampling_config_for_model( platform.oauth().is_some() && platform.wire_api() == kigi_models::PlatformWireApi::Messages }); + // GitHub Copilot editor-identity headers: a managed key whose platform is + // github-copilot drives the editor headers + X-Initiator in the sampler. + // Gated here so every other ChatCompletions platform stays byte-identical. + let github_copilot = info + .id + .as_deref() + .and_then(kigi_models::parse_managed_model_key) + .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); SamplerConfig { api_key: credentials.api_key, model: model_name, @@ -4101,6 +4109,7 @@ pub fn sampling_config_for_model( api_backend, auth_scheme: credentials.auth_scheme, anthropic_oauth, + github_copilot, chat_compat, extra_headers, context_window: info.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index dacbcfa..fe51d80 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -359,6 +359,30 @@ fn fetch_one_platform_models( .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION) .header("anthropic-beta", kigi_sampling_types::ANTHROPIC_OAUTH_BETA); } + // GitHub Copilot /models needs the VS Code editor identity + the + // Copilot API version. github-copilot-GATED, so every other Bearer + // OpenAI-listing platform (xai-grok, api-key OpenAI rows) is + // byte-identical. + if platform.sends_copilot_editor_headers() { + req = req + .header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT) + .header( + "Editor-Version", + kigi_sampling_types::COPILOT_EDITOR_VERSION, + ) + .header( + "Editor-Plugin-Version", + kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION, + ) + .header( + "Copilot-Integration-Id", + kigi_sampling_types::COPILOT_INTEGRATION_ID, + ) + .header( + "X-GitHub-Api-Version", + kigi_sampling_types::COPILOT_API_VERSION, + ); + } req } kigi_models::PlatformKeyHeader::XApiKey => client @@ -378,6 +402,20 @@ fn fetch_one_platform_models( .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let data = match platform.listing() { + // GitHub Copilot serves an OpenAI-shape listing with extra availability + // fields (model_picker_enabled/policy/tool_calls). Parse + filter it + // with the Copilot-specific adapter (keep only selectable, tool-calling, + // openai-completions-served ids). Platform-gated; every other OpenAi + // listing takes the plain path. + kigi_models::ListingDialect::OpenAi if platform.sends_copilot_editor_headers() => { + let body = response.text()?; + kigi_models::parse_github_copilot_listing(&body).map_err(|e| { + BackendError::RequestFailed { + status: 200, + body: format!("copilot listing parse failed: {e}"), + } + })? + } kigi_models::ListingDialect::OpenAi => { // Tolerant of both the {data:[...]} envelope and a bare array // (Together AI serves the bare form). @@ -1231,6 +1269,138 @@ mod tests { ); } + /// GitHub Copilot OAuth fetch e2e (mock wire): `GET /models` gated on the + /// Bearer COPILOT token + the VS Code editor headers + X-GitHub-Api-Version + /// returns a mix (a good completions model, a claude-4.x messages model, a + /// gpt-5 responses-only model, and a disabled model). The catalog keeps ONLY + /// the completions-served enabled tool-calling model, keyed `github-copilot/ + /// ` on the ChatCompletions backend, enriched from models.dev + /// "github-copilot". The bearer is drawn from the copilot OAuth-session map, + /// never a Kimi session or an API key. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn github_copilot_oauth_listing_filters_and_keys_completions_models() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + // Bearer COPILOT token + the editor identity + the Copilot API + // version — the request MUST carry all of them or the mock 404s. + .and(wiremock::matchers::header( + "Authorization", + "Bearer copilot-session-tok", + )) + .and(wiremock::matchers::header( + "User-Agent", + "GitHubCopilotChat/0.35.0", + )) + .and(wiremock::matchers::header( + "Editor-Version", + "vscode/1.107.0", + )) + .and(wiremock::matchers::header( + "Copilot-Integration-Id", + "vscode-chat", + )) + .and(wiremock::matchers::header( + "X-GitHub-Api-Version", + "2026-06-01", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + // KEPT: completions-served, selectable, tool-calling. + { "id": "gpt-4.1", "name": "GPT-4.1", "model_picker_enabled": true, + "policy": {"state": "enabled"}, + "capabilities": {"supports": {"tool_calls": true}} }, + // DROPPED: claude-4.x → anthropic-messages wire (excluded). + { "id": "claude-opus-4-8", "model_picker_enabled": true, + "capabilities": {"supports": {"tool_calls": true}} }, + // DROPPED: gpt-5* → responses-only (excluded). + { "id": "gpt-5.2", "model_picker_enabled": true, + "capabilities": {"supports": {"tool_calls": true}} }, + // DROPPED: policy disabled. + { "id": "gemini-3-flash-preview", "model_picker_enabled": true, + "policy": {"state": "disabled"} } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "github-copilot": { "models": { + "gpt-4.1": { + "limit": {"context": 128000, "output": 16384}, + "tool_call": true + } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::COPILOT_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + + let endpoints = crate::agent::config::EndpointsConfig::default(); + // The listing bearer comes from the github-copilot OAuth-session map, + // never a Kimi session (auth=None) or an API key (keys empty). + let mut oauth_tokens = OAuthSessionTokens::new(); + oauth_tokens.insert( + kigi_models::PlatformId::GithubCopilot, + "copilot-session-tok".to_string(), + ); + let keys = crate::agent::models::PlatformApiKeys::default(); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys) + }) + .await + .unwrap() + .expect("github-copilot oauth fetch must succeed"); + + assert_eq!( + result + .models + .iter() + .map(|m| m.id.as_deref().unwrap_or_default()) + .collect::>(), + vec!["github-copilot/gpt-4.1"], + "only the completions-served, enabled, tool-calling model survives \ + (claude-4.x, gpt-5, and the disabled model are dropped)" + ); + let entry = &result.models[0]; + assert_eq!( + entry.api_backend, + crate::sampling::ApiBackend::ChatCompletions, + "github-copilot speaks the ChatCompletions wire" + ); + assert_eq!( + entry.auth_scheme, None, + "OAuth Bearer entries carry no XApiKey auth scheme" + ); + assert_eq!(entry.name.as_deref(), Some("GPT-4.1")); + assert_eq!( + entry.context_window.get(), + 128_000, + "context window comes from models.dev github-copilot enrichment" + ); + assert!( + !entry.supported_in_api, + "subscription (uses_oauth) models require the OAuth session" + ); + } + /// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts /// (high/max) produce ChatCompletions entries whose sampler config /// speaks the DeepSeek thinking dialect. diff --git a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs index ecfd3f0..3d74936 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs @@ -888,6 +888,11 @@ async fn read_parent_sampling_config( platform.oauth().is_some() && platform.wire_api() == kigi_models::PlatformWireApi::Messages }); + // GitHub Copilot editor headers inherit from the parent model's + // platform (github-copilot → true); every other platform / BYOK → + // false, so the other ChatCompletions paths stay byte-identical. + let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref()) + .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()); let inherited = kigi_sampler::SamplerConfig { api_key: creds.api_key, base_url: cfg.base_url, @@ -898,6 +903,7 @@ async fn read_parent_sampling_config( api_backend: cfg.api_backend, auth_scheme, anthropic_oauth, + github_copilot, chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/auth/device_code.rs b/crates/codegen/kigi-shell/src/auth/device_code.rs index 1003331..aa510c8 100644 --- a/crates/codegen/kigi-shell/src/auth/device_code.rs +++ b/crates/codegen/kigi-shell/src/auth/device_code.rs @@ -27,8 +27,15 @@ const SLOW_DOWN_INCREMENT_SECS: u64 = 5; /// pre-generalization path; the `Generic` arm drives a registry /// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`]. enum DeviceFlowBackend<'a> { - Kimi { host: &'a str }, + Kimi { + host: &'a str, + }, Generic(&'a OAuthConfig), + /// GitHub Copilot two-stage flow: the device authorization is the generic + /// one, but the token POLL reads GitHub's 200-body errors, and login + /// FINALIZES the durable github token into a copilot session token via the + /// Stage-2 exchange (see [`DeviceFlowBackend::finalize`]). + GithubCopilot(&'a OAuthConfig), } impl DeviceFlowBackend<'_> { @@ -37,7 +44,7 @@ impl DeviceFlowBackend<'_> { Self::Kimi { host } => { crate::auth::kimi_oauth::request_device_authorization(host).await } - Self::Generic(cfg) => { + Self::Generic(cfg) | Self::GithubCopilot(cfg) => { crate::auth::oauth_device::request_device_authorization(cfg).await } } @@ -50,6 +57,22 @@ impl DeviceFlowBackend<'_> { Self::Generic(cfg) => { crate::auth::oauth_device::poll_device_token(cfg, device_code).await } + Self::GithubCopilot(cfg) => { + crate::auth::github_copilot::poll_github_device_token(cfg, device_code).await + } + } + } + /// Transform the device-grant credential before it is persisted. The Kimi + /// and generic flows persist the poll result verbatim; the GitHub Copilot + /// flow exchanges the durable github token (in `auth.key`) for the + /// short-lived copilot token, persisting BOTH (copilot as `key`, github as + /// `refresh_token`). + async fn finalize(&self, auth: KimiAuth) -> anyhow::Result { + match self { + Self::Kimi { .. } | Self::Generic(_) => Ok(auth), + Self::GithubCopilot(cfg) => { + crate::auth::github_copilot::exchange_copilot_token(cfg, &auth.key).await + } } } } @@ -86,6 +109,23 @@ pub async fn run_device_code_login_generic( run_device_code_login_backend(DeviceFlowBackend::Generic(oauth), auth_manager, channels).await } +/// GitHub Copilot two-stage login (github-copilot): the same device-flow +/// presentation as the generic path, but the token poll reads GitHub's 200-body +/// errors and the minted github token is finalized into a copilot session token +/// before it is persisted (see [`DeviceFlowBackend::finalize`]). +pub async fn run_device_code_login_github_copilot( + oauth: &OAuthConfig, + auth_manager: &Arc, + channels: &mut Option, +) -> anyhow::Result<(KimiAuth, bool)> { + run_device_code_login_backend( + DeviceFlowBackend::GithubCopilot(oauth), + auth_manager, + channels, + ) + .await +} + async fn run_device_code_login_backend( backend: DeviceFlowBackend<'_>, auth_manager: &Arc, @@ -114,8 +154,12 @@ async fn run_device_code_login_backend( match complete_device_code_login(&backend, &device_auth).await? { PollLoopOutcome::Done(auth) => { + // Finalize before persisting: the GitHub Copilot flow exchanges + // the durable github token for the short-lived copilot token + // here; the Kimi / generic flows pass the credential through. + let auth = backend.finalize(*auth).await?; let auth = auth_manager - .update(*auth) + .update(auth) .await .map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?; return Ok((auth, true)); @@ -388,6 +432,100 @@ mod tests { ); } + /// GitHub Copilot two-stage login e2e (mock wire): device authorization → + /// poll (pending → github token) → Stage-2 copilot-token exchange (Bearer + /// github token + editor headers → copilot token + expiry). The persisted + /// credential keys the COPILOT token, keeps the GITHUB token as + /// `refresh_token`, and carries the copilot expiry. + #[tokio::test] + async fn github_copilot_two_stage_login_persists_copilot_and_github() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + let cfg = OAuthConfig { + auth_host: host, + token_host: host, + copilot_exchange: Some((host, "/copilot_internal/v2/token")), + ..kigi_models::COPILOT_OAUTH_CONFIG + }; + // Stage 1a: device authorization (github.com/login/device/code). + Mock::given(method("POST")) + .and(path("/login/device/code")) + .and(body_string_contains("client_id=Iv1.b507a08c87ecfe98")) + .and(body_string_contains("scope=read")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "device_code": "gh-dev-1", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 0, + }))) + .mount(&server) + .await; + // Stage 1b: token poll — GitHub returns errors AND success in a 200 body. + Mock::given(method("POST")) + .and(path("/login/oauth/access_token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "error": "authorization_pending" })), + ) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/login/oauth/access_token")) + .and(body_string_contains("device_code=gh-dev-1")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })), + ) + .mount(&server) + .await; + // Stage 2: copilot-token exchange (Bearer github token + editor headers). + let future = (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(); + Mock::given(method("GET")) + .and(path("/copilot_internal/v2/token")) + .and(wiremock::matchers::header( + "Authorization", + "Bearer gho_github_tok", + )) + .and(wiremock::matchers::header( + "Editor-Plugin-Version", + "copilot-chat/0.35.0", + )) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "token": "copilot-session-tok", "expires_at": future }), + )) + .expect(1) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let mgr = auth_manager(&dir); + let mut channels = None; + let (auth, is_new) = run_device_code_login_github_copilot(&cfg, &mgr, &mut channels) + .await + .unwrap(); + assert!(is_new); + assert_eq!( + auth.key, "copilot-session-tok", + "key = copilot session token" + ); + assert_eq!( + auth.refresh_token.as_deref(), + Some("gho_github_tok"), + "the durable github token is persisted as refresh_token" + ); + assert!( + auth.expires_at.is_some_and(|e| e > chrono::Utc::now()), + "the copilot expiry must be persisted" + ); + assert_eq!( + mgr.current_or_expired().map(|a| a.key), + Some("copilot-session-tok".into()), + "login must land the copilot token in the manager cache" + ); + } + /// A 5xx from the token endpoint is a hard error (kimi-cli parity). #[tokio::test] async fn server_error_during_poll_fails_login() { diff --git a/crates/codegen/kigi-shell/src/auth/flow.rs b/crates/codegen/kigi-shell/src/auth/flow.rs index e0977c5..598e42a 100644 --- a/crates/codegen/kigi-shell/src/auth/flow.rs +++ b/crates/codegen/kigi-shell/src/auth/flow.rs @@ -107,6 +107,14 @@ pub async fn run_oauth_provider_flow( kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => { run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await } + kigi_models::OAuthFlow::GithubDeviceCopilot => { + crate::auth::device_code::run_device_code_login_github_copilot( + oauth, + auth_manager, + &mut channels, + ) + .await + } } } diff --git a/crates/codegen/kigi-shell/src/auth/github_copilot.rs b/crates/codegen/kigi-shell/src/auth/github_copilot.rs new file mode 100644 index 0000000..3f0dbf6 --- /dev/null +++ b/crates/codegen/kigi-shell/src/auth/github_copilot.rs @@ -0,0 +1,485 @@ +//! GitHub Copilot two-stage OAuth wire (github-copilot), driven by a registry +//! [`kigi_models::OAuthConfig`] whose `flow` is [`OAuthFlow::GithubDeviceCopilot`]. +//! +//! Stage 1 — RFC-8628 device flow on `auth_host` (github.com). The device +//! authorization POST is the generic one ([`super::oauth_device`]); the token +//! POLL is Copilot-specific because GitHub returns its device errors in a `200` +//! body (`{error: "authorization_pending"|"slow_down"|"expired_token"}`), not a +//! `4xx`, and the success payload carries ONLY `access_token` (the DURABLE +//! GitHub token — no refresh token, no expiry). +//! +//! Stage 2 — copilot-token exchange: `GET {copilot_exchange}` bearing the GitHub +//! token + the editor headers re-mints the SHORT-LIVED copilot session token +//! (`{token, expires_at}`). This runs at login ([`exchange_copilot_token`]) and +//! on every "refresh" ([`remint_copilot_token`], dispatched by the generic +//! refresher) — the github token is unchanged and re-persisted as the +//! `refresh_token`; the copilot token becomes the `key`. +//! +//! SECURITY: the github token and the copilot token are NEVER logged (only +//! non-secret events: poll succeeded, copilot token minted/re-minted). + +use chrono::{DateTime, Utc}; +use kigi_models::OAuthConfig; +use serde::Deserialize; + +use super::kimi_oauth::{DevicePollResult, RefreshError}; +use super::model::{AuthMode, KimiAuth}; + +/// RFC-8628 device grant type (shared with the generic device wire). +const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; +/// Copilot-exchange retry budget over 5xx / network blips (parity with the +/// device/PKCE refresh wires); 401/403 fails fast (the github token is dead). +const MAX_EXCHANGE_RETRIES: u32 = 3; +const RETRYABLE_EXCHANGE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504]; + +/// The four VS Code Copilot editor-identity headers every Copilot request +/// carries. Non-secret wire constants owned by `kigi_sampling_types` (the +/// single source shared with the sampler's inference gate). +fn editor_headers() -> [(&'static str, &'static str); 4] { + [ + ("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT), + ( + "Editor-Version", + kigi_sampling_types::COPILOT_EDITOR_VERSION, + ), + ( + "Editor-Plugin-Version", + kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION, + ), + ( + "Copilot-Integration-Id", + kigi_sampling_types::COPILOT_INTEGRATION_ID, + ), + ] +} + +/// GitHub's device-token poll response: EITHER `access_token` (the durable +/// GitHub token) OR an `error` (in a `200` body). No refresh token / expiry. +#[derive(Deserialize, Default)] +struct GithubDeviceTokenResponse { + #[serde(default)] + access_token: Option, + #[serde(default)] + error: Option, +} + +/// One poll of `POST {auth_host}{token_path}` (github.com/login/oauth/ +/// access_token) with the device grant. GitHub answers `200` for BOTH success +/// and the pending/slow_down/expired errors, so the outcome is read from the +/// body, not the status. On success the [`KimiAuth`] carries the GitHub token as +/// `key` with NO refresh token / expiry — the caller finalizes it via the +/// copilot exchange before persisting. +pub(crate) async fn poll_github_device_token( + cfg: &OAuthConfig, + device_code: &str, +) -> anyhow::Result { + let url = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.token_path); + let resp = crate::http::shared_client() + .post(&url) + .header("Accept", "application/json") + .header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT) + .form(&[ + ("client_id", cfg.client_id), + ("device_code", device_code), + ("grant_type", DEVICE_GRANT_TYPE), + ]) + .send() + .await + .map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?; + + let status = resp.status(); + if status.is_server_error() { + anyhow::bail!("Token polling server error: {status}"); + } + let body = resp.bytes().await?; + let parsed: GithubDeviceTokenResponse = serde_json::from_slice(&body).unwrap_or_default(); + + if let Some(access) = parsed.access_token.filter(|t| !t.is_empty()) { + tracing::info!("auth: github device poll succeeded, github token issued (copilot)"); + return Ok(DevicePollResult::Success(Box::new(github_token_auth( + access, + )))); + } + match parsed.error.as_deref() { + Some("expired_token") => { + tracing::info!("auth: github device code expired; restarting (copilot)"); + Ok(DevicePollResult::Expired) + } + Some(error) => { + tracing::debug!(error, "auth: github device poll pending (copilot)"); + Ok(DevicePollResult::Pending { + error: error.to_owned(), + description: None, + }) + } + None => Ok(DevicePollResult::Pending { + error: "missing_access_token".to_owned(), + description: None, + }), + } +} + +/// A transient [`KimiAuth`] holding ONLY the durable GitHub token (no refresh +/// token / expiry) — the intermediate device-flow result, finalized by the +/// copilot exchange before it is ever persisted. +fn github_token_auth(github_token: String) -> KimiAuth { + KimiAuth { + key: github_token, + auth_mode: AuthMode::OAuth, + create_time: Utc::now(), + user_id: String::new(), + email: None, + refresh_token: None, + expires_at: None, + expires_in: None, + scope: None, + token_type: None, + } +} + +/// Stage-2 copilot-token exchange response (`GET copilot_internal/v2/token`). +/// `endpoints`/`proxy-ep` are ignored: Kigi resolves the base URL from the +/// platform registry (the individual-subscription endpoint, or the +/// `KIGI_COPILOT_BASE_URL` override). +#[derive(Deserialize)] +struct CopilotTokenResponse { + token: String, + /// Unix seconds when the copilot token expires (~30 min out). + expires_at: i64, +} + +/// Materialize the persisted credential from a copilot-token exchange: +/// `key` = the short-lived copilot token, `refresh_token` = the DURABLE github +/// token (so every re-mint re-exchanges it), `expires_at` = the copilot expiry. +fn copilot_auth(resp: CopilotTokenResponse, github_token: &str) -> anyhow::Result { + let now = Utc::now(); + // FAIL-FAST: an uninterpretable expiry means we cannot schedule the re-mint, + // so reject it rather than silently falling back to a long default TTL — which + // would let the ~30-min copilot token 401 on the wire ~30 min later. + let expires_at = DateTime::from_timestamp(resp.expires_at, 0).ok_or_else(|| { + anyhow::anyhow!( + "copilot token has an out-of-range expires_at: {}", + resp.expires_at + ) + })?; + // The manager's dynamic threshold (`max(300, expires_in × 0.5)`) drives the + // proactive re-mint; `expires_in` is the copilot token's remaining life. + let expires_in = (expires_at - now).num_seconds(); + Ok(KimiAuth { + key: resp.token, + auth_mode: AuthMode::OAuth, + create_time: now, + user_id: String::new(), + email: None, + refresh_token: Some(github_token.to_owned()), + expires_at: Some(expires_at), + expires_in: Some(expires_in), + scope: None, + token_type: Some("bearer".to_owned()), + }) +} + +/// The `(host, path)` of the copilot-token exchange endpoint, or a fatal error +/// when the config lacks it (a non-Copilot config reaching this wire is a bug). +fn exchange_url(cfg: &OAuthConfig) -> anyhow::Result { + let (host, path) = cfg.copilot_exchange.ok_or_else(|| { + anyhow::anyhow!("github-copilot config missing copilot_exchange endpoint") + })?; + Ok(format!("{}{path}", host.trim_end_matches('/'))) +} + +/// `GET {copilot_exchange}` bearing the github token + editor headers. +async fn send_copilot_exchange( + cfg: &OAuthConfig, + github_token: &str, +) -> anyhow::Result { + let url = exchange_url(cfg)?; + let mut req = crate::http::shared_client() + .get(&url) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {github_token}")); + for (name, value) in editor_headers() { + req = req.header(name, value); + } + req.send() + .await + .map_err(|e| anyhow::anyhow!("copilot-token exchange request failed: {e}")) +} + +/// Exchange the durable GitHub token for a copilot session token (login path). +/// FAIL-FAST: a non-2xx response aborts login (never a silent fallback). +pub(crate) async fn exchange_copilot_token( + cfg: &OAuthConfig, + github_token: &str, +) -> anyhow::Result { + tracing::info!( + scope_key = cfg.scope_key, + "auth: exchanging github token for copilot token" + ); + let resp = send_copilot_exchange(cfg, github_token).await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + tracing::warn!(%status, scope_key = cfg.scope_key, "auth: copilot-token exchange failed"); + anyhow::bail!("Copilot token exchange failed (HTTP {status}): {body}"); + } + let parsed: CopilotTokenResponse = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("malformed copilot token payload: {e}"))?; + tracing::info!(scope_key = cfg.scope_key, "auth: copilot token minted"); + copilot_auth(parsed, github_token) +} + +/// Re-mint the copilot token from the durable GitHub token (refresh path, +/// dispatched from the generic refresher). This is NOT a `refresh_token` grant: +/// it re-runs the copilot exchange. `refresh_token` is the github token; the +/// returned [`KimiAuth`] preserves it. Retries 5xx / network blips; 401/403 +/// (the github token is revoked) fails fast as [`RefreshError::Unauthorized`]. +pub(crate) async fn remint_copilot_token( + cfg: &OAuthConfig, + github_token: &str, +) -> Result { + let mut last_error = String::from("no attempt made"); + for attempt in 0..MAX_EXCHANGE_RETRIES { + if attempt > 0 { + let backoff = std::time::Duration::from_secs(1 << (attempt - 1)); + tracing::warn!( + attempt, + backoff_secs = backoff.as_secs(), + "auth: retrying copilot-token re-mint" + ); + tokio::time::sleep(backoff).await; + } + let resp = match send_copilot_exchange(cfg, github_token).await { + Ok(resp) => resp, + Err(e) => { + last_error = format!("{e}"); + continue; + } + }; + let status = resp.status().as_u16(); + let bytes = resp.bytes().await.unwrap_or_default(); + if status == 401 || status == 403 { + return Err(RefreshError::Unauthorized { + status, + description: "GitHub token rejected at copilot-token exchange.".to_owned(), + }); + } + if status == 200 { + return match serde_json::from_slice::(&bytes) { + Ok(parsed) => match copilot_auth(parsed, github_token) { + Ok(auth) => { + tracing::info!(scope_key = cfg.scope_key, "auth: copilot token re-minted"); + Ok(auth) + } + Err(e) => Err(RefreshError::Fatal { + status, + description: format!("{e}"), + }), + }, + Err(e) => Err(RefreshError::Fatal { + status, + description: format!("malformed copilot token payload: {e}"), + }), + }; + } + let description = format!("copilot-token exchange failed (HTTP {status})."); + if RETRYABLE_EXCHANGE_STATUSES.contains(&status) { + last_error = description; + continue; + } + return Err(RefreshError::Fatal { + status, + description, + }); + } + Err(RefreshError::Exhausted { last_error }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use kigi_models::COPILOT_OAUTH_CONFIG; + use wiremock::matchers::{body_string_contains, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// A COPILOT_OAUTH_CONFIG pointed at a mock server for both stages. + fn mock_cfg(host: &'static str, exchange: &'static str) -> OAuthConfig { + OAuthConfig { + auth_host: host, + token_host: host, + copilot_exchange: Some((exchange, "/copilot_internal/v2/token")), + ..COPILOT_OAUTH_CONFIG + } + } + + /// GitHub's device poll returns pending errors in a 200 body — mapped to + /// Pending (authorization_pending / slow_down) and Expired (expired_token), + /// never mis-read as a token. + #[tokio::test] + async fn github_device_poll_maps_200_body_errors() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("POST")) + .and(path("/login/oauth/access_token")) + .and(body_string_contains("grant_type=urn")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "error": "authorization_pending" })), + ) + .mount(&server) + .await; + let result = poll_github_device_token(&mock_cfg(host, host), "dev-1") + .await + .unwrap(); + assert!( + matches!(result, DevicePollResult::Pending { error, .. } if error == "authorization_pending") + ); + } + + #[tokio::test] + async fn github_device_poll_expired_restarts() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("POST")) + .and(path("/login/oauth/access_token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "error": "expired_token" })), + ) + .mount(&server) + .await; + let result = poll_github_device_token(&mock_cfg(host, host), "dev-1") + .await + .unwrap(); + assert!(matches!(result, DevicePollResult::Expired)); + } + + /// A successful poll yields the DURABLE github token as `key` with NO + /// refresh token / expiry (the copilot exchange finalizes it next). + #[tokio::test] + async fn github_device_poll_success_is_bare_github_token() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("POST")) + .and(path("/login/oauth/access_token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })), + ) + .mount(&server) + .await; + let DevicePollResult::Success(auth) = poll_github_device_token(&mock_cfg(host, host), "d") + .await + .unwrap() + else { + panic!("expected success"); + }; + assert_eq!(auth.key, "gho_github_tok"); + assert_eq!( + auth.refresh_token, None, + "github token is not a refresh grant" + ); + assert_eq!(auth.expires_at, None, "the github token is long-lived"); + } + + /// The Stage-2 exchange rides the github Bearer + editor headers and maps + /// `{token, expires_at}` onto `key=copilot`, `refresh_token=github`, with a + /// future `expires_at`. + #[tokio::test] + async fn copilot_exchange_maps_token_and_persists_github_as_refresh() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + let future = (Utc::now() + Duration::minutes(30)).timestamp(); + Mock::given(method("GET")) + .and(path("/copilot_internal/v2/token")) + .and(header("Authorization", "Bearer gho_github_tok")) + .and(header("Editor-Version", "vscode/1.107.0")) + .and(header("Copilot-Integration-Id", "vscode-chat")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "token": "tid=abc;copilot-tok", "expires_at": future }), + )) + .expect(1) + .mount(&server) + .await; + let auth = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok") + .await + .unwrap(); + assert_eq!(auth.key, "tid=abc;copilot-tok", "key = copilot token"); + assert_eq!( + auth.refresh_token.as_deref(), + Some("gho_github_tok"), + "the durable github token is persisted as refresh_token" + ); + assert!( + auth.expires_at.is_some_and(|e| e > Utc::now()), + "copilot expiry must be in the future" + ); + } + + /// The copilot re-mint (refresh) re-exchanges the github token for a NEW + /// copilot token, keeping the github token as refresh_token. + #[tokio::test] + async fn copilot_remint_returns_new_copilot_token() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + let future = (Utc::now() + Duration::minutes(30)).timestamp(); + Mock::given(method("GET")) + .and(path("/copilot_internal/v2/token")) + .and(header("Authorization", "Bearer gho_github_tok")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "token": "copilot-tok-2", "expires_at": future }), + )) + .mount(&server) + .await; + let auth = remint_copilot_token(&mock_cfg(host, host), "gho_github_tok") + .await + .unwrap(); + assert_eq!(auth.key, "copilot-tok-2"); + assert_eq!(auth.refresh_token.as_deref(), Some("gho_github_tok")); + } + + /// A 401 at the exchange (github token revoked) fails fast as Unauthorized + /// (drives the manager's permanent-failure / re-login path). + #[tokio::test] + async fn copilot_remint_401_is_unauthorized() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("GET")) + .and(path("/copilot_internal/v2/token")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + let err = remint_copilot_token(&mock_cfg(host, host), "dead-github-tok") + .await + .unwrap_err(); + assert!( + matches!(err, RefreshError::Unauthorized { status: 401, .. }), + "got {err:?}" + ); + } + + /// FAIL-FAST: an out-of-range `expires_at` is rejected rather than silently + /// degrading to a long default TTL (which would 401 on the wire ~30 min in). + #[tokio::test] + async fn copilot_exchange_rejects_out_of_range_expiry() { + let server = MockServer::start().await; + let host: &'static str = Box::leak(server.uri().into_boxed_str()); + Mock::given(method("GET")) + .and(path("/copilot_internal/v2/token")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "token": "copilot-tok", "expires_at": i64::MAX }), + )) + .mount(&server) + .await; + let err = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok") + .await + .unwrap_err(); + assert!( + format!("{err}").contains("out-of-range expires_at"), + "got {err}" + ); + } +} diff --git a/crates/codegen/kigi-shell/src/auth/mod.rs b/crates/codegen/kigi-shell/src/auth/mod.rs index 1f3b4fe..f8ff10e 100644 --- a/crates/codegen/kigi-shell/src/auth/mod.rs +++ b/crates/codegen/kigi-shell/src/auth/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod device; pub mod device_code; pub mod error; mod flow; +pub(crate) mod github_copilot; pub(crate) mod kimi_oauth; pub(crate) mod manager; mod model; diff --git a/crates/codegen/kigi-shell/src/auth/oauth_registry.rs b/crates/codegen/kigi-shell/src/auth/oauth_registry.rs index 0ca3af6..7f45406 100644 --- a/crates/codegen/kigi-shell/src/auth/oauth_registry.rs +++ b/crates/codegen/kigi-shell/src/auth/oauth_registry.rs @@ -147,6 +147,43 @@ mod tests { .expect("claude-pro-max carries an OAuthConfig") } + fn copilot_oauth() -> &'static kigi_models::OAuthConfig { + kigi_models::PlatformId::GithubCopilot + .oauth() + .expect("github-copilot carries an OAuthConfig") + } + + /// A `github-copilot/` turn resolves to the process-global pooled + /// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the + /// primary Kimi manager — the same leak-safe routing as xai-grok / + /// claude-pro-max, and a DISTINCT pool entry from either. + #[tokio::test] + async fn github_copilot_model_resolves_to_its_own_manager_not_kimi() { + let (_kd, kimi) = primary_with_token("kimi-tok"); + let home = tempfile::tempdir().unwrap(); + let resolved = manager_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi)) + .expect("github-copilot model resolves to its pooled manager"); + assert!( + !Arc::ptr_eq(&resolved, &kimi), + "github-copilot must NOT resolve to the Kimi manager" + ); + assert!( + Arc::ptr_eq(&resolved, &global_manager_for(home.path(), copilot_oauth())), + "github-copilot must resolve to its OWN process-global pooled manager" + ); + assert!( + !Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())), + "github-copilot and claude-pro-max must not share a pooled manager" + ); + // Fail-fast: even with a Kimi primary, a copilot turn never yields the + // Kimi bearer — it draws from the copilot pool (its own token, or None). + assert_ne!( + session_key_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi)), + Some("kimi-tok".to_string()), + "a github-copilot model must never receive the primary Kimi token" + ); + } + /// A `claude-pro-max/` turn resolves to the process-global pooled /// claude-pro-max manager (its OWN `oauth/claude-pro-max` scope), NEVER the /// primary Kimi manager — the same leak-safe routing as xai-grok, and a diff --git a/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs b/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs index cc41249..b68ee2c 100644 --- a/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs +++ b/crates/codegen/kigi-shell/src/auth/refresh/generic_refresher.rs @@ -14,7 +14,7 @@ use kigi_models::{OAuthConfig, OAuthTokenBody}; use crate::auth::error::RefreshTokenFailedReason; use crate::auth::kimi_oauth::RefreshError; use crate::auth::manager::RefreshReason; -use crate::auth::{oauth_device, oauth_pkce}; +use crate::auth::{github_copilot, oauth_device, oauth_pkce}; use super::{AuthSnapshot, RefreshOutcome, TokenRefresher}; @@ -105,11 +105,17 @@ impl TokenRefresher for GenericDeviceRefresher { ); // Refresh over the provider's token-body encoding: xai's endpoint is - // form-encoded (device wire); Claude's is JSON (PKCE wire). Both return - // the same `Result`. + // form-encoded (device wire); Claude's is JSON (PKCE wire); GitHub + // Copilot's "refresh" is a copilot-token RE-MINT — a `GET + // copilot_internal/v2/token` bearing the durable github token (the + // `refresh_token` field here), NOT a refresh_token grant. All three + // return the same `Result`. let wire_result = match self.cfg.token_body { OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await, OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await, + OAuthTokenBody::GithubCopilotExchange => { + github_copilot::remint_copilot_token(self.cfg, &refresh_token).await + } }; match wire_result { Ok(new_auth) => { diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs index 233d038..1f493c7 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/sampler_turn.rs @@ -274,6 +274,15 @@ impl SessionActor { }, ) } + /// Whether `model` routes to the GitHub Copilot ChatCompletions platform + /// (github-copilot) — the gate for the sampler's editor-identity headers + + /// `X-Initiator`. Every other model returns `false`, keeping the other + /// ChatCompletions providers byte-identical. + fn model_is_github_copilot(&self, model: &str) -> bool { + let managed_key = self.managed_key_for_model(model); + kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)) + .is_some_and(|(platform, _)| platform.sends_copilot_editor_headers()) + } /// LEAK guard for the stamped aux paths (auto-mode classifier, image /// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`] /// has copied the SESSION model's `bearer_resolver` onto an aux @@ -383,6 +392,8 @@ impl SessionActor { // Claude Pro/Max OAuth Messages adaptation for THIS turn's model // (captured before `cfg.model` is moved into the struct below). let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model); + // GitHub Copilot editor-identity headers for THIS turn's model. + let github_copilot = self.model_is_github_copilot(&cfg.model); let auth_scheme = model_facts.auth_scheme; let mut extra_headers = cfg.extra_headers; crate::agent::config::inject_url_derived_headers( @@ -424,6 +435,7 @@ impl SessionActor { api_backend: cfg.api_backend, auth_scheme, anthropic_oauth, + github_copilot, chat_compat: cfg.chat_compat, extra_headers, context_window: cfg.context_window.get(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs index da7a0a9..3c1fe07 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/auth_error_no_retry_tests.rs @@ -850,6 +850,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() { chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index ad60b01..f1011d1 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -47,6 +47,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() { chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: Default::default(), context_window: 100_000, force_http1: false, @@ -344,6 +345,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() { chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, context_window: 100_000, force_http1: false, max_retries: None, @@ -475,6 +477,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, context_window: 100_000, force_http1: false, max_retries: None, @@ -1744,6 +1747,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: Default::default(), context_window: 100_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs index 58d2727..8d5fe3c 100644 --- a/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs +++ b/crates/codegen/kigi-shell/src/session/helpers/session_compact.rs @@ -1591,6 +1591,7 @@ mod reasoning_compaction_regression_tests { chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs index 3e10b0c..1f6b4de 100644 --- a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs @@ -47,6 +47,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> SubagentSpawnCon chat_compat: Default::default(), auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, extra_headers: Default::default(), context_window: 256_000, force_http1: false, diff --git a/crates/codegen/kigi-shell/tests/common/mod.rs b/crates/codegen/kigi-shell/tests/common/mod.rs index 1cfe592..da091b7 100644 --- a/crates/codegen/kigi-shell/tests/common/mod.rs +++ b/crates/codegen/kigi-shell/tests/common/mod.rs @@ -39,6 +39,7 @@ pub fn test_sampler_config( api_backend, auth_scheme: Default::default(), anthropic_oauth: false, + github_copilot: false, chat_compat: Default::default(), extra_headers: extra_headers .iter() diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index dde44fd..6fcc42a 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 28, "27 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 29, "28 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -6910,22 +6910,27 @@ pub(crate) mod tests { "row 2 must be the claude-pro-max OAuth login (after xai-grok), got {:?}", items[2] ); + assert!( + matches!(&items[3], PendingMenuItem::Login { label } if label == "GitHub Copilot (subscription) (OAuth)"), + "row 3 must be the github-copilot OAuth login (after claude-pro-max), got {:?}", + items[3] + ); assert_eq!( - items[3], + items[4], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn), label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(), } ); assert_eq!( - items[4], + items[5], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi), label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), } ); assert_eq!( - items[5], + items[6], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::OpenAi), label: "OpenAI (API key)".into(), @@ -6933,153 +6938,153 @@ pub(crate) mod tests { "new registry rows must appear in the picker with zero TUI changes" ); assert_eq!( - items[6], + items[7], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic), label: "Anthropic (API key)".into(), } ); assert_eq!( - items[7], + items[8], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek), label: "DeepSeek (API key)".into(), } ); assert_eq!( - items[8], + items[9], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Groq), label: "Groq (API key)".into(), } ); assert_eq!( - items[9], + items[10], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Mistral), label: "Mistral (API key)".into(), } ); assert_eq!( - items[10], + items[11], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks), label: "Fireworks AI (API key)".into(), } ); assert_eq!( - items[11], + items[12], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Google), label: "Google Gemini (API key)".into(), } ); assert_eq!( - items[12], + items[13], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::OpenRouter), label: "OpenRouter (API key)".into(), } ); assert_eq!( - items[13], + items[14], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Together), label: "Together AI (API key)".into(), } ); assert_eq!( - items[14], + items[15], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras), label: "Cerebras (API key)".into(), } ); assert_eq!( - items[15], + items[16], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Nvidia), label: "NVIDIA NIM (API key)".into(), } ); assert_eq!( - items[16], + items[17], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Vercel), label: "Vercel AI Gateway (API key)".into(), } ); assert_eq!( - items[17], + items[18], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Xai), label: "xAI (Grok) (API key)".into(), } ); assert_eq!( - items[18], + items[19], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlan), label: "Qwen Token Plan (API key)".into(), } ); assert_eq!( - items[19], + items[20], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::QwenTokenPlanCn), label: "Qwen Token Plan China (API key)".into(), } ); assert_eq!( - items[20], + items[21], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::KimiCoding), label: "Kimi For Coding (API key)".into(), } ); assert_eq!( - items[21], + items[22], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Zai), label: "Z.AI (API key)".into(), } ); assert_eq!( - items[22], + items[23], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::ZaiCodingCn), label: "Z.AI Coding China (API key)".into(), } ); assert_eq!( - items[23], + items[24], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Xiaomi), label: "Xiaomi MiMo (API key)".into(), } ); assert_eq!( - items[24], + items[25], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::XiaomiTokenPlanCn), label: "Xiaomi Token Plan China (API key)".into(), } ); assert_eq!( - items[25], + items[26], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::Minimax), label: "MiniMax (API key)".into(), } ); assert_eq!( - items[26], + items[27], PendingMenuItem::ApiKey { target: PlatformLogin(kigi_shell::models::PlatformId::MinimaxCn), label: "MiniMax China (API key)".into(), } ); - assert_eq!(items[27], PendingMenuItem::Quit); + assert_eq!(items[28], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -7090,8 +7095,8 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 28, - "xai.api_key / cached_token must not add rows (27 login rows + Quit)" + 29, + "xai.api_key / cached_token must not add rows (28 login rows + Quit)" ); } /// Startup lands on the picker only when there is a real choice: the @@ -7113,8 +7118,9 @@ pub(crate) mod tests { app.auth_state = AuthState::Pending { error: None }; app.welcome_prompt_focused = false; // Interactive OAuth logins come first: row 0 (kimi-code), row 1 - // (xai-grok), row 2 (claude-pro-max); the first API-key row - // (moonshot-cn) is now row 3, so four Downs land on it. + // (xai-grok), row 2 (claude-pro-max), row 3 (github-copilot); the first + // API-key row (moonshot-cn) is now row 4, so five Downs land on it. + app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index 45c4233..8ca1753 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -2114,6 +2114,10 @@ mod tests { text.contains("Claude Pro/Max (subscription) (OAuth)"), "the claude-pro-max interactive OAuth login row must render: {text}" ); + assert!( + text.contains("GitHub Copilot (subscription) (OAuth)"), + "the github-copilot interactive OAuth login row must render: {text}" + ); assert!( text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), "{text}"