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
+36 -30
View File
@@ -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));
@@ -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}"