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(),