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
@@ -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,