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
@@ -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,
+98 -2
View File
@@ -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).
@@ -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,
@@ -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,