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
+32 -4
View File
@@ -175,7 +175,8 @@ edges stay deterministic Rust. The harness appends a terminal
scope-keyed `AuthManager::new_oauth_provider` +
`refresh::GenericDeviceRefresher` (selected by `build_refresher` via
`oauth_config_for_scope_key`; the refresher dispatches the refresh body by
`token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`). Kimi
`token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`,
`GithubCopilotExchange``auth::github_copilot` copilot-token re-mint). Kimi
Code keeps `oauth: None` and its bespoke path unchanged. The interactive
login is dispatched by `OAuthConfig.flow` (in `run_oauth_provider_flow`):
- `OAuthFlow::DeviceCode``auth::oauth_device` (RFC-8628 device-code, plain
@@ -192,9 +193,36 @@ edges stay deterministic Rust. The harness appends a terminal
prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so
API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its
`/v1/models` listing rides the same Bearer + oauth-beta headers.
Both are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok` then
`claude-pro-max`). The catalog fetch resolves each such platform's OWN session
- `OAuthFlow::GithubDeviceCopilot``auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
RFC-8628 device flow on `github.com` (client `Iv1.b507a08c87ecfe98`, scope
`read:user`) whose errors ride a `200` body (not `4xx`) — it mints the
DURABLE github token. Stage 2 (`GET api.github.com/copilot_internal/v2/token`
with `copilot_exchange` + editor headers) re-mints the SHORT-LIVED copilot
token. Persisted as `KimiAuth.key = copilot token`, `refresh_token = github
token`, `expires_at = copilot expiry`; the "refresh" is a copilot-token
RE-MINT (GET, not a `refresh_token` grant). Every `/models` listing AND
`/chat/completions` request carries the VS Code editor-identity headers
(`User-Agent GitHubCopilotChat/…`, `Editor-Version`, `Editor-Plugin-Version`,
`Copilot-Integration-Id`; `+X-GitHub-Api-Version` on `/models`, `+X-Initiator
user` on inference) — gated on `SamplerConfig.github_copilot` /
`PlatformId::sends_copilot_editor_headers()` so every other ChatCompletions
provider stays byte-identical. WIRE-COMPAT SCOPE: Kigi is one-wire-per-
platform, so the catalog is FILTERED (`parse_github_copilot_listing`) to the
openai-completions-served models — keep iff `model_picker_enabled` &&
`policy.state != "disabled"` && `tool_calls != false` AND the id is NOT a
`claude-(haiku|sonnet|opus)-[45]` (anthropic-messages) or `gpt-5/oswe/mai-`
(responses-only) model. Those excluded models need per-model wire routing
(deferred, documented debt), NOT included lest they fail at inference.
KNOWN LIMITATION: Kigi does NOT port Pi's per-model policy-acceptance step
(`POST {base}/models/{id}/policy {state:"enabled"}`). A kept model whose
Copilot policy is unconfigured can list yet `403` at inference until the user
enables it once in GitHub's UI — a deliberate omission (it mutates account
state and is unverifiable without a live Copilot account), not a silent gap.
These are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok`,
`claude-pro-max`, `github-copilot`). The catalog fetch resolves each such platform's OWN session
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()``platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.