feat(providers): add GitHub Copilot subscription OAuth (device flow + copilot-token re-mint)

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).
This commit is contained in:
2026-07-22 04:21:02 -04:00
parent 5a9183b08b
commit 8179438278
25 changed files with 1432 additions and 44 deletions
@@ -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",
@@ -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(),
@@ -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/
/// <id>` 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<_>>(),
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.
@@ -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(),
@@ -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<KimiAuth> {
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<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> 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<AuthManager>,
@@ -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() {
@@ -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
}
}
}
@@ -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<String>,
#[serde(default)]
error: Option<String>,
}
/// 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<DevicePollResult> {
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<KimiAuth> {
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<String> {
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<reqwest::Response> {
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<KimiAuth> {
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<KimiAuth, RefreshError> {
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::<CopilotTokenResponse>(&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}"
);
}
}
@@ -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;
@@ -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/<model>` 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/<model>` 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
@@ -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<KimiAuth, RefreshError>`.
// 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<KimiAuth, RefreshError>`.
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) => {
@@ -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(),
@@ -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,
@@ -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,
@@ -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,
@@ -47,6 +47,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> 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,
@@ -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()