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.
+334 -2
View File
@@ -109,6 +109,13 @@ pub enum OAuthFlow {
/// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port`
/// loopback listener, then is exchanged at `token_host`+`token_path`.
PkceLocalhost { redirect_port: u16 },
/// GitHub Copilot two-stage flow (github-copilot): an RFC-8628 device flow
/// on `auth_host` (github.com) mints the DURABLE GitHub token, which is then
/// exchanged at `copilot_exchange` for the SHORT-LIVED copilot session
/// token. The github token is persisted as `refresh_token`; the copilot
/// token as `key`. GitHub's poll returns errors in a `200` body (not `4xx`),
/// so it drives a Copilot-specific poll, not the generic device wire.
GithubDeviceCopilot,
}
/// Body encoding a provider's token endpoint expects for the code-exchange and
@@ -120,6 +127,11 @@ pub enum OAuthTokenBody {
Form,
/// `application/json` (Claude PKCE wire).
Json,
/// GitHub Copilot copilot-token re-mint (github-copilot): "refresh" is NOT
/// a `refresh_token` grant — it is a `GET copilot_internal/v2/token` bearing
/// the durable GitHub token (`refresh_token` field) + editor headers, which
/// re-mints the short-lived copilot token. Dispatched to the Copilot wire.
GithubCopilotExchange,
}
/// Generic OAuth configuration carried by a `uses_oauth` platform whose login
@@ -159,6 +171,13 @@ pub struct OAuthConfig {
pub flow: OAuthFlow,
/// Body encoding the token endpoint expects (form vs JSON).
pub token_body: OAuthTokenBody,
/// Second-stage token-exchange endpoint `(host, path)` for the GitHub
/// Copilot two-stage flow (github-copilot): `("https://api.github.com",
/// "/copilot_internal/v2/token")`. The durable GitHub token is exchanged
/// here for the short-lived copilot token, at BOTH login and every re-mint
/// "refresh". `None` for the standard flows (xai-grok, claude-pro-max),
/// whose refresh is a plain `refresh_token` grant against `token_host`.
pub copilot_exchange: Option<(&'static str, &'static str)>,
}
/// xAI / Grok subscription device-code OAuth (ported from Pi
@@ -174,6 +193,7 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
extra_device_field: Some(("referrer", "kigi")),
flow: OAuthFlow::DeviceCode,
token_body: OAuthTokenBody::Form,
copilot_exchange: None,
};
/// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape
@@ -197,6 +217,32 @@ pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
redirect_port: 53692,
},
token_body: OAuthTokenBody::Json,
copilot_exchange: None,
};
/// Base-URL override for the GitHub Copilot inference/listing channel
/// (dev/test escape hatch). Production defaults to the individual-subscription
/// endpoint `https://api.individual.githubcopilot.com`.
pub const COPILOT_BASE_URL_ENV: &str = "KIGI_COPILOT_BASE_URL";
/// GitHub Copilot subscription OAuth (two-stage device flow → copilot-token
/// exchange). Authoritative constants from Pi `earendil-works/pi`
/// `auth/oauth/github-copilot.ts`: the VS Code Copilot Chat public client id,
/// the github.com device endpoints, and the `api.github.com/copilot_internal/
/// v2/token` copilot-token exchange (Stage 2). The device flow mints the
/// durable GitHub token; the exchange re-mints the short-lived copilot token.
pub const COPILOT_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
client_id: "Iv1.b507a08c87ecfe98",
auth_host: "https://github.com",
device_path: "/login/device/code",
token_host: "https://github.com",
token_path: "/login/oauth/access_token",
scope: "read:user",
scope_key: "oauth/github-copilot",
extra_device_field: None,
flow: OAuthFlow::GithubDeviceCopilot,
token_body: OAuthTokenBody::GithubCopilotExchange,
copilot_exchange: Some(("https://api.github.com", "/copilot_internal/v2/token")),
};
/// The generic device-code OAuth config for a platform, or `None` for API-key
@@ -1101,6 +1147,43 @@ const CLAUDE_PRO_MAX_SPEC: PlatformSpec = PlatformSpec {
strip_listing_id_prefix: None,
};
const GITHUB_COPILOT_SPEC: PlatformSpec = PlatformSpec {
id: "github-copilot",
display_name: "GitHub Copilot",
base_url: BaseUrlSource::EnvOr {
env: COPILOT_BASE_URL_ENV,
default: "https://api.individual.githubcopilot.com",
},
uses_oauth: true,
oauth: Some(&COPILOT_OAUTH_CONFIG),
allowed_model_prefixes: None,
// OAuth channel: no API key envs (the copilot session is the bearer).
api_key_envs: &[],
vendor: "GitHub",
console_host: Some("github.com"),
login_label: Some("GitHub Copilot (subscription)"),
models_dev_id: Some("github-copilot"),
// Copilot /models serves availability flags (model_picker_enabled/policy/
// tool_calls), NOT context/thinking metadata — enrich from models.dev.
wire_serves_metadata: false,
// ChatCompletions ONLY: the catalog is filtered (see
// `parse_github_copilot_listing`) to the openai-completions-served models.
// The claude-4.x/5.x (messages) and gpt-5/oswe/mai- (responses-only) models
// this endpoint also lists are EXCLUDED — Kigi is one-wire-per-platform and
// per-model wire routing is deferred (documented limitation).
wire_api: PlatformWireApi::ChatCompletions,
// OpenAI-shape listing endpoint, but with Copilot-specific availability
// fields — parsed by `parse_github_copilot_listing`, gated on the platform.
listing: ListingDialect::OpenAi,
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::Bearer,
// The copilot listing filter + the wire-compat filter govern the catalog,
// NOT the enrichment membership (a live but enrichment-lagging model stays).
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -1160,12 +1243,15 @@ pub enum PlatformId {
/// Claude Pro/Max subscription via PKCE-localhost OAuth (Anthropic Messages
/// wire reached with an OAuth bearer instead of an API key).
ClaudeProMax,
/// GitHub Copilot subscription via the two-stage device-code OAuth flow
/// (ChatCompletions wire reached with the short-lived copilot token).
GithubCopilot,
}
impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 27] = [
pub const ALL: [PlatformId; 28] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
@@ -1193,6 +1279,7 @@ impl PlatformId {
Self::MinimaxCn,
Self::XaiGrok,
Self::ClaudeProMax,
Self::GithubCopilot,
];
/// The registry row backing this platform (single source of per-platform
@@ -1226,6 +1313,7 @@ impl PlatformId {
Self::MinimaxCn => &MINIMAX_CN_SPEC,
Self::XaiGrok => &XAI_GROK_SPEC,
Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC,
Self::GithubCopilot => &GITHUB_COPILOT_SPEC,
}
}
@@ -1352,6 +1440,16 @@ impl PlatformId {
pub fn chat_compat(self) -> PlatformChatCompat {
self.spec().chat_compat
}
/// True ONLY for `github-copilot`: its `/models` listing and every
/// `/chat/completions` inference request must carry the VS Code Copilot
/// editor-identity headers (User-Agent / Editor-Version /
/// Editor-Plugin-Version / Copilot-Integration-Id). The shell gates the
/// listing headers and the sampler gates the inference headers on this, so
/// every other ChatCompletions platform's request stays byte-identical.
pub fn sends_copilot_editor_headers(self) -> bool {
matches!(self, Self::GithubCopilot)
}
}
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
@@ -1675,6 +1773,128 @@ pub fn filter_allowed_models(platform: PlatformId, models: Vec<WireModel>) -> Ve
.collect()
}
// ── GitHub Copilot listing adapter (github-copilot) ─────────────────────────
/// A Copilot model id that routes to the anthropic-messages wire in Pi
/// (`/^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/`) — EXCLUDED from Kigi's
/// ChatCompletions catalog. Matches `claude-{haiku,sonnet,opus}-` followed by a
/// single `4`/`5` and then `.`, `-`, or end (so `claude-fable-5` is NOT a match
/// and stays served; `claude-sonnet-45` is not a real family and does not match).
fn is_copilot_messages_claude(id: &str) -> bool {
for family in ["claude-haiku-", "claude-sonnet-", "claude-opus-"] {
let Some(rest) = id.strip_prefix(family) else {
continue;
};
let mut chars = rest.chars();
if matches!(chars.next(), Some('4') | Some('5'))
&& matches!(chars.next(), None | Some('.') | Some('-'))
{
return true;
}
}
false
}
/// A Copilot model id served ONLY through the `/responses` endpoint in Pi
/// (`gpt-5*` / `oswe*` / `mai-*`) — EXCLUDED from Kigi's ChatCompletions catalog.
fn is_copilot_responses_only(id: &str) -> bool {
id.starts_with("gpt-5") || id.starts_with("oswe") || id.starts_with("mai-")
}
/// Whether a Copilot model id is served by the openai-completions wire — the
/// ONLY wire Kigi's github-copilot platform speaks. Excludes the claude-4.x/5.x
/// (messages) and gpt-5/oswe/mai- (responses-only) ids that would fail at
/// inference on `/chat/completions` (documented per-model-routing limitation).
pub fn is_copilot_completions_served(id: &str) -> bool {
!is_copilot_messages_claude(id) && !is_copilot_responses_only(id)
}
#[derive(serde::Deserialize)]
struct CopilotListing {
/// No default: a 200 body without `data` is a contract violation and must
/// error like the OpenAI-shape branch, not yield an empty catalog.
data: Vec<CopilotListingModel>,
}
#[derive(serde::Deserialize)]
struct CopilotListingModel {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
model_picker_enabled: bool,
#[serde(default)]
policy: Option<CopilotPolicy>,
#[serde(default)]
capabilities: Option<CopilotCapabilities>,
}
#[derive(serde::Deserialize)]
struct CopilotPolicy {
#[serde(default)]
state: Option<String>,
}
#[derive(serde::Deserialize)]
struct CopilotCapabilities {
#[serde(default)]
supports: Option<CopilotSupports>,
}
#[derive(serde::Deserialize)]
struct CopilotSupports {
/// Absent means "not declined" → allowed (Pi: `supports.tool_calls !==
/// false`). Only an explicit `false` drops the model.
#[serde(default)]
tool_calls: Option<bool>,
}
/// Parse the GitHub Copilot `GET {base}/models` response and apply BOTH filters
/// (a Copilot quirk, gated on the platform):
/// 1. availability — keep iff `model_picker_enabled == true` AND
/// `policy.state != "disabled"` AND `capabilities.supports.tool_calls !=
/// false` (Pi `isSelectableCopilotModel`);
/// 2. wire-compat — keep iff the id is openai-completions-served (drops the
/// claude-4.x/5.x messages models and the gpt-5/oswe/mai- responses-only
/// models, which Kigi's single-wire platform cannot route).
///
/// Metadata (context window, thinking) is NOT served here — enrichment from
/// models.dev "github-copilot" fills it downstream.
pub fn parse_github_copilot_listing(json: &str) -> Result<Vec<WireModel>, serde_json::Error> {
let listing: CopilotListing = serde_json::from_str(json)?;
let kept = listing
.data
.into_iter()
.filter(|m| {
let picker_enabled = m.model_picker_enabled;
let policy_ok = m
.policy
.as_ref()
.and_then(|p| p.state.as_deref())
.is_none_or(|state| state != "disabled");
let tool_calls_ok = m
.capabilities
.as_ref()
.and_then(|c| c.supports.as_ref())
.and_then(|s| s.tool_calls)
!= Some(false);
picker_enabled && policy_ok && tool_calls_ok && is_copilot_completions_served(&m.id)
})
.map(|m| WireModel {
id: m.id,
context_length: 0,
supports_reasoning: false,
supports_image_in: false,
supports_video_in: false,
display_name: m.name,
max_output_tokens: 0,
supports_thinking_type: None,
think_efforts: None,
})
.collect();
Ok(kept)
}
// ── Bundled offline fallback catalog ────────────────────────────────────────
/// The raw JSON, embedded at compile time. OFFLINE LAST RESORT: consulted only
@@ -2097,6 +2317,117 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.claude/v1");
}
/// github-copilot is the two-stage device-code OAuth platform: it carries a
/// `GithubDeviceCopilot`/`GithubCopilotExchange` `OAuthConfig` with a
/// copilot-token exchange endpoint (Stage 2), speaks the ChatCompletions
/// wire with a Bearer copilot token + the editor-headers gate, enriches from
/// models.dev "github-copilot", and keys its models under `github-copilot/`.
#[test]
fn github_copilot_is_a_two_stage_oauth_platform() {
let g = PlatformId::GithubCopilot;
assert_eq!(g.as_str(), "github-copilot");
assert!(g.uses_oauth());
assert!(
g.sends_copilot_editor_headers(),
"github-copilot must gate the editor-identity headers"
);
// Every OTHER platform must NOT send the editor headers (regression).
for other in PlatformId::ALL {
if other != PlatformId::GithubCopilot {
assert!(
!other.sends_copilot_editor_headers(),
"{} must not send the copilot editor headers",
other.as_str()
);
}
}
let cfg = g
.oauth()
.expect("github-copilot carries a two-stage OAuthConfig");
assert_eq!(cfg, &COPILOT_OAUTH_CONFIG);
assert_eq!(cfg.client_id, "Iv1.b507a08c87ecfe98");
assert_eq!(cfg.auth_host, "https://github.com");
assert_eq!(cfg.device_path, "/login/device/code");
assert_eq!(cfg.token_path, "/login/oauth/access_token");
assert_eq!(cfg.scope, "read:user");
assert_eq!(cfg.scope_key, "oauth/github-copilot");
assert_eq!(cfg.flow, OAuthFlow::GithubDeviceCopilot);
assert_eq!(cfg.token_body, OAuthTokenBody::GithubCopilotExchange);
assert_eq!(
cfg.copilot_exchange,
Some(("https://api.github.com", "/copilot_internal/v2/token")),
"the Stage-2 copilot-token exchange endpoint must be configured"
);
// xai/claude carry no copilot exchange (their refresh is a plain grant).
assert_eq!(XAI_OAUTH_CONFIG.copilot_exchange, None);
assert_eq!(CLAUDE_OAUTH_CONFIG.copilot_exchange, None);
// Scope-key lookup resolves the config (drives the generic refresher's
// Copilot re-mint dispatch).
assert_eq!(
oauth_config_for_scope_key("oauth/github-copilot"),
Some(&COPILOT_OAUTH_CONFIG)
);
// ChatCompletions wire, Bearer, models.dev "github-copilot", own base.
assert_eq!(g.models_dev_id(), Some("github-copilot"));
assert_eq!(g.wire_api(), PlatformWireApi::ChatCompletions);
assert_eq!(g.listing(), ListingDialect::OpenAi);
assert_eq!(g.key_header(), PlatformKeyHeader::Bearer);
assert_eq!(g.api_key_env_names(), &[] as &[&str]);
assert!(!g.restrict_to_enriched());
assert_eq!(g.managed_model_key("gpt-4.1"), "github-copilot/gpt-4.1");
let _guard = kigi_env::EnvVarGuard::set(COPILOT_BASE_URL_ENV, "https://mock.copilot");
assert_eq!(g.base_url(), "https://mock.copilot");
}
/// The Copilot `/models` filter keeps ONLY openai-completions-served,
/// selectable, tool-calling models — dropping the claude-4.x/5.x (messages)
/// and gpt-5/oswe/mai- (responses-only) ids AND the disabled / picker-off /
/// tool-call-declining ones — with the display name carried through.
#[test]
fn copilot_listing_filters_by_availability_and_wire_compat() {
let body = serde_json::json!({ "data": [
// KEPT: completions-served, picker on, no policy, tool_calls absent.
{ "id": "gpt-4.1", "name": "GPT-4.1", "model_picker_enabled": true },
// KEPT: policy enabled + tool_calls true.
{ "id": "gemini-3-flash-preview", "model_picker_enabled": true,
"policy": {"state": "enabled"},
"capabilities": {"supports": {"tool_calls": true}} },
// KEPT: claude-fable-5 is NOT a messages-routed claude family.
{ "id": "claude-fable-5", "model_picker_enabled": true },
// DROPPED: claude-4.x → anthropic-messages wire.
{ "id": "claude-opus-4-8", "model_picker_enabled": true },
// DROPPED: claude-5.x → anthropic-messages wire.
{ "id": "claude-sonnet-5", "model_picker_enabled": true },
// DROPPED: gpt-5* → responses-only.
{ "id": "gpt-5.2", "model_picker_enabled": true },
// DROPPED: mai-* → responses-only.
{ "id": "mai-code-1", "model_picker_enabled": true },
// DROPPED: picker disabled.
{ "id": "gpt-4o", "model_picker_enabled": false },
// DROPPED: policy disabled.
{ "id": "kimi-k2.7-code", "model_picker_enabled": true,
"policy": {"state": "disabled"} },
// DROPPED: tool_calls explicitly false.
{ "id": "text-embed", "model_picker_enabled": true,
"capabilities": {"supports": {"tool_calls": false}} }
]})
.to_string();
let kept = parse_github_copilot_listing(&body).expect("valid listing");
let ids: Vec<&str> = kept.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["gpt-4.1", "gemini-3-flash-preview", "claude-fable-5"],
"only completions-served, selectable, tool-calling models survive"
);
assert_eq!(
kept[0].display_name.as_deref(),
Some("GPT-4.1"),
"the wire display name must carry through"
);
// A 200 body without `data` is a contract violation (never empty catalog).
assert!(parse_github_copilot_listing("{}").is_err());
}
/// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain
/// literal) but is silently unparseable and excluded from model sync.
/// The exhaustive match below fails compilation when a variant is added,
@@ -2132,9 +2463,10 @@ mod tests {
PlatformId::MinimaxCn => 24,
PlatformId::XaiGrok => 25,
PlatformId::ClaudeProMax => 26,
PlatformId::GithubCopilot => 27,
}
}
const VARIANT_COUNT: usize = 27; // update together with `ordinal`
const VARIANT_COUNT: usize = 28; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();
@@ -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,
@@ -1076,6 +1076,28 @@ pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
pub const CLAUDE_CODE_SYSTEM_PREFIX: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
// ── GitHub Copilot editor-identity headers ──────────────────────────────────
// The VS Code Copilot Chat client identity. Copilot's proxy authorizes the
// short-lived copilot token AND validates these editor headers, so they ride
// the `copilot_internal/v2/token` exchange, the `/models` listing, and every
// `/chat/completions` inference request. github-copilot-GATED: no other
// platform sends them, so their requests stay byte-identical. Values are
// non-secret wire constants (ported from Pi `api/github-copilot-headers.ts` +
// `auth/oauth/github-copilot.ts`).
/// `User-Agent` for the Copilot path (overrides the default kigi UA, OAuth-gated).
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
/// `Editor-Version` — the host editor Copilot believes it is talking to.
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0";
/// `Editor-Plugin-Version` — the Copilot Chat plugin build.
pub const COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
/// `Copilot-Integration-Id` — the integration the token is scoped to.
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
/// `X-GitHub-Api-Version` — sent ONLY on the `/models` listing.
pub const COPILOT_API_VERSION: &str = "2026-06-01";
/// `X-Initiator` value — sent ONLY on inference (`user`, per the spec).
pub const COPILOT_INITIATOR: &str = "user";
/// ChatCompletions request-body adaptation dialect. Providers disagree on
/// how thinking rides an OpenAI-compatible body: Kimi wants
/// `thinking:{type,effort}`, DeepSeek wants
@@ -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(),
@@ -27,8 +27,15 @@ const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
/// pre-generalization path; the `Generic` arm drives a registry
/// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`].
enum DeviceFlowBackend<'a> {
Kimi { host: &'a str },
Kimi {
host: &'a str,
},
Generic(&'a OAuthConfig),
/// GitHub Copilot two-stage flow: the device authorization is the generic
/// one, but the token POLL reads GitHub's 200-body errors, and login
/// FINALIZES the durable github token into a copilot session token via the
/// Stage-2 exchange (see [`DeviceFlowBackend::finalize`]).
GithubCopilot(&'a OAuthConfig),
}
impl DeviceFlowBackend<'_> {
@@ -37,7 +44,7 @@ impl DeviceFlowBackend<'_> {
Self::Kimi { host } => {
crate::auth::kimi_oauth::request_device_authorization(host).await
}
Self::Generic(cfg) => {
Self::Generic(cfg) | Self::GithubCopilot(cfg) => {
crate::auth::oauth_device::request_device_authorization(cfg).await
}
}
@@ -50,6 +57,22 @@ impl DeviceFlowBackend<'_> {
Self::Generic(cfg) => {
crate::auth::oauth_device::poll_device_token(cfg, device_code).await
}
Self::GithubCopilot(cfg) => {
crate::auth::github_copilot::poll_github_device_token(cfg, device_code).await
}
}
}
/// Transform the device-grant credential before it is persisted. The Kimi
/// and generic flows persist the poll result verbatim; the GitHub Copilot
/// flow exchanges the durable github token (in `auth.key`) for the
/// short-lived copilot token, persisting BOTH (copilot as `key`, github as
/// `refresh_token`).
async fn finalize(&self, auth: KimiAuth) -> anyhow::Result<KimiAuth> {
match self {
Self::Kimi { .. } | Self::Generic(_) => Ok(auth),
Self::GithubCopilot(cfg) => {
crate::auth::github_copilot::exchange_copilot_token(cfg, &auth.key).await
}
}
}
}
@@ -86,6 +109,23 @@ pub async fn run_device_code_login_generic(
run_device_code_login_backend(DeviceFlowBackend::Generic(oauth), auth_manager, channels).await
}
/// GitHub Copilot two-stage login (github-copilot): the same device-flow
/// presentation as the generic path, but the token poll reads GitHub's 200-body
/// errors and the minted github token is finalized into a copilot session token
/// before it is persisted (see [`DeviceFlowBackend::finalize`]).
pub async fn run_device_code_login_github_copilot(
oauth: &OAuthConfig,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(
DeviceFlowBackend::GithubCopilot(oauth),
auth_manager,
channels,
)
.await
}
async fn run_device_code_login_backend(
backend: DeviceFlowBackend<'_>,
auth_manager: &Arc<AuthManager>,
@@ -114,8 +154,12 @@ async fn run_device_code_login_backend(
match complete_device_code_login(&backend, &device_auth).await? {
PollLoopOutcome::Done(auth) => {
// Finalize before persisting: the GitHub Copilot flow exchanges
// the durable github token for the short-lived copilot token
// here; the Kimi / generic flows pass the credential through.
let auth = backend.finalize(*auth).await?;
let auth = auth_manager
.update(*auth)
.update(auth)
.await
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
return Ok((auth, true));
@@ -388,6 +432,100 @@ mod tests {
);
}
/// GitHub Copilot two-stage login e2e (mock wire): device authorization →
/// poll (pending → github token) → Stage-2 copilot-token exchange (Bearer
/// github token + editor headers → copilot token + expiry). The persisted
/// credential keys the COPILOT token, keeps the GITHUB token as
/// `refresh_token`, and carries the copilot expiry.
#[tokio::test]
async fn github_copilot_two_stage_login_persists_copilot_and_github() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let cfg = OAuthConfig {
auth_host: host,
token_host: host,
copilot_exchange: Some((host, "/copilot_internal/v2/token")),
..kigi_models::COPILOT_OAUTH_CONFIG
};
// Stage 1a: device authorization (github.com/login/device/code).
Mock::given(method("POST"))
.and(path("/login/device/code"))
.and(body_string_contains("client_id=Iv1.b507a08c87ecfe98"))
.and(body_string_contains("scope=read"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"device_code": "gh-dev-1",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 0,
})))
.mount(&server)
.await;
// Stage 1b: token poll — GitHub returns errors AND success in a 200 body.
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.and(body_string_contains("device_code=gh-dev-1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })),
)
.mount(&server)
.await;
// Stage 2: copilot-token exchange (Bearer github token + editor headers).
let future = (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(wiremock::matchers::header(
"Authorization",
"Bearer gho_github_tok",
))
.and(wiremock::matchers::header(
"Editor-Plugin-Version",
"copilot-chat/0.35.0",
))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-session-tok", "expires_at": future }),
))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let mgr = auth_manager(&dir);
let mut channels = None;
let (auth, is_new) = run_device_code_login_github_copilot(&cfg, &mgr, &mut channels)
.await
.unwrap();
assert!(is_new);
assert_eq!(
auth.key, "copilot-session-tok",
"key = copilot session token"
);
assert_eq!(
auth.refresh_token.as_deref(),
Some("gho_github_tok"),
"the durable github token is persisted as refresh_token"
);
assert!(
auth.expires_at.is_some_and(|e| e > chrono::Utc::now()),
"the copilot expiry must be persisted"
);
assert_eq!(
mgr.current_or_expired().map(|a| a.key),
Some("copilot-session-tok".into()),
"login must land the copilot token in the manager cache"
);
}
/// A 5xx from the token endpoint is a hard error (kimi-cli parity).
#[tokio::test]
async fn server_error_during_poll_fails_login() {
@@ -107,6 +107,14 @@ pub async fn run_oauth_provider_flow(
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => {
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await
}
kigi_models::OAuthFlow::GithubDeviceCopilot => {
crate::auth::device_code::run_device_code_login_github_copilot(
oauth,
auth_manager,
&mut channels,
)
.await
}
}
}
@@ -0,0 +1,485 @@
//! GitHub Copilot two-stage OAuth wire (github-copilot), driven by a registry
//! [`kigi_models::OAuthConfig`] whose `flow` is [`OAuthFlow::GithubDeviceCopilot`].
//!
//! Stage 1 — RFC-8628 device flow on `auth_host` (github.com). The device
//! authorization POST is the generic one ([`super::oauth_device`]); the token
//! POLL is Copilot-specific because GitHub returns its device errors in a `200`
//! body (`{error: "authorization_pending"|"slow_down"|"expired_token"}`), not a
//! `4xx`, and the success payload carries ONLY `access_token` (the DURABLE
//! GitHub token — no refresh token, no expiry).
//!
//! Stage 2 — copilot-token exchange: `GET {copilot_exchange}` bearing the GitHub
//! token + the editor headers re-mints the SHORT-LIVED copilot session token
//! (`{token, expires_at}`). This runs at login ([`exchange_copilot_token`]) and
//! on every "refresh" ([`remint_copilot_token`], dispatched by the generic
//! refresher) — the github token is unchanged and re-persisted as the
//! `refresh_token`; the copilot token becomes the `key`.
//!
//! SECURITY: the github token and the copilot token are NEVER logged (only
//! non-secret events: poll succeeded, copilot token minted/re-minted).
use chrono::{DateTime, Utc};
use kigi_models::OAuthConfig;
use serde::Deserialize;
use super::kimi_oauth::{DevicePollResult, RefreshError};
use super::model::{AuthMode, KimiAuth};
/// RFC-8628 device grant type (shared with the generic device wire).
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
/// Copilot-exchange retry budget over 5xx / network blips (parity with the
/// device/PKCE refresh wires); 401/403 fails fast (the github token is dead).
const MAX_EXCHANGE_RETRIES: u32 = 3;
const RETRYABLE_EXCHANGE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// The four VS Code Copilot editor-identity headers every Copilot request
/// carries. Non-secret wire constants owned by `kigi_sampling_types` (the
/// single source shared with the sampler's inference gate).
fn editor_headers() -> [(&'static str, &'static str); 4] {
[
("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT),
(
"Editor-Version",
kigi_sampling_types::COPILOT_EDITOR_VERSION,
),
(
"Editor-Plugin-Version",
kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION,
),
(
"Copilot-Integration-Id",
kigi_sampling_types::COPILOT_INTEGRATION_ID,
),
]
}
/// GitHub's device-token poll response: EITHER `access_token` (the durable
/// GitHub token) OR an `error` (in a `200` body). No refresh token / expiry.
#[derive(Deserialize, Default)]
struct GithubDeviceTokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
error: Option<String>,
}
/// One poll of `POST {auth_host}{token_path}` (github.com/login/oauth/
/// access_token) with the device grant. GitHub answers `200` for BOTH success
/// and the pending/slow_down/expired errors, so the outcome is read from the
/// body, not the status. On success the [`KimiAuth`] carries the GitHub token as
/// `key` with NO refresh token / expiry — the caller finalizes it via the
/// copilot exchange before persisting.
pub(crate) async fn poll_github_device_token(
cfg: &OAuthConfig,
device_code: &str,
) -> anyhow::Result<DevicePollResult> {
let url = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.token_path);
let resp = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT)
.form(&[
("client_id", cfg.client_id),
("device_code", device_code),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?;
let status = resp.status();
if status.is_server_error() {
anyhow::bail!("Token polling server error: {status}");
}
let body = resp.bytes().await?;
let parsed: GithubDeviceTokenResponse = serde_json::from_slice(&body).unwrap_or_default();
if let Some(access) = parsed.access_token.filter(|t| !t.is_empty()) {
tracing::info!("auth: github device poll succeeded, github token issued (copilot)");
return Ok(DevicePollResult::Success(Box::new(github_token_auth(
access,
))));
}
match parsed.error.as_deref() {
Some("expired_token") => {
tracing::info!("auth: github device code expired; restarting (copilot)");
Ok(DevicePollResult::Expired)
}
Some(error) => {
tracing::debug!(error, "auth: github device poll pending (copilot)");
Ok(DevicePollResult::Pending {
error: error.to_owned(),
description: None,
})
}
None => Ok(DevicePollResult::Pending {
error: "missing_access_token".to_owned(),
description: None,
}),
}
}
/// A transient [`KimiAuth`] holding ONLY the durable GitHub token (no refresh
/// token / expiry) — the intermediate device-flow result, finalized by the
/// copilot exchange before it is ever persisted.
fn github_token_auth(github_token: String) -> KimiAuth {
KimiAuth {
key: github_token,
auth_mode: AuthMode::OAuth,
create_time: Utc::now(),
user_id: String::new(),
email: None,
refresh_token: None,
expires_at: None,
expires_in: None,
scope: None,
token_type: None,
}
}
/// Stage-2 copilot-token exchange response (`GET copilot_internal/v2/token`).
/// `endpoints`/`proxy-ep` are ignored: Kigi resolves the base URL from the
/// platform registry (the individual-subscription endpoint, or the
/// `KIGI_COPILOT_BASE_URL` override).
#[derive(Deserialize)]
struct CopilotTokenResponse {
token: String,
/// Unix seconds when the copilot token expires (~30 min out).
expires_at: i64,
}
/// Materialize the persisted credential from a copilot-token exchange:
/// `key` = the short-lived copilot token, `refresh_token` = the DURABLE github
/// token (so every re-mint re-exchanges it), `expires_at` = the copilot expiry.
fn copilot_auth(resp: CopilotTokenResponse, github_token: &str) -> anyhow::Result<KimiAuth> {
let now = Utc::now();
// FAIL-FAST: an uninterpretable expiry means we cannot schedule the re-mint,
// so reject it rather than silently falling back to a long default TTL — which
// would let the ~30-min copilot token 401 on the wire ~30 min later.
let expires_at = DateTime::from_timestamp(resp.expires_at, 0).ok_or_else(|| {
anyhow::anyhow!(
"copilot token has an out-of-range expires_at: {}",
resp.expires_at
)
})?;
// The manager's dynamic threshold (`max(300, expires_in × 0.5)`) drives the
// proactive re-mint; `expires_in` is the copilot token's remaining life.
let expires_in = (expires_at - now).num_seconds();
Ok(KimiAuth {
key: resp.token,
auth_mode: AuthMode::OAuth,
create_time: now,
user_id: String::new(),
email: None,
refresh_token: Some(github_token.to_owned()),
expires_at: Some(expires_at),
expires_in: Some(expires_in),
scope: None,
token_type: Some("bearer".to_owned()),
})
}
/// The `(host, path)` of the copilot-token exchange endpoint, or a fatal error
/// when the config lacks it (a non-Copilot config reaching this wire is a bug).
fn exchange_url(cfg: &OAuthConfig) -> anyhow::Result<String> {
let (host, path) = cfg.copilot_exchange.ok_or_else(|| {
anyhow::anyhow!("github-copilot config missing copilot_exchange endpoint")
})?;
Ok(format!("{}{path}", host.trim_end_matches('/')))
}
/// `GET {copilot_exchange}` bearing the github token + editor headers.
async fn send_copilot_exchange(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<reqwest::Response> {
let url = exchange_url(cfg)?;
let mut req = crate::http::shared_client()
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {github_token}"));
for (name, value) in editor_headers() {
req = req.header(name, value);
}
req.send()
.await
.map_err(|e| anyhow::anyhow!("copilot-token exchange request failed: {e}"))
}
/// Exchange the durable GitHub token for a copilot session token (login path).
/// FAIL-FAST: a non-2xx response aborts login (never a silent fallback).
pub(crate) async fn exchange_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<KimiAuth> {
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging github token for copilot token"
);
let resp = send_copilot_exchange(cfg, github_token).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: copilot-token exchange failed");
anyhow::bail!("Copilot token exchange failed (HTTP {status}): {body}");
}
let parsed: CopilotTokenResponse = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("malformed copilot token payload: {e}"))?;
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token minted");
copilot_auth(parsed, github_token)
}
/// Re-mint the copilot token from the durable GitHub token (refresh path,
/// dispatched from the generic refresher). This is NOT a `refresh_token` grant:
/// it re-runs the copilot exchange. `refresh_token` is the github token; the
/// returned [`KimiAuth`] preserves it. Retries 5xx / network blips; 401/403
/// (the github token is revoked) fails fast as [`RefreshError::Unauthorized`].
pub(crate) async fn remint_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> Result<KimiAuth, RefreshError> {
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_EXCHANGE_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
"auth: retrying copilot-token re-mint"
);
tokio::time::sleep(backoff).await;
}
let resp = match send_copilot_exchange(cfg, github_token).await {
Ok(resp) => resp,
Err(e) => {
last_error = format!("{e}");
continue;
}
};
let status = resp.status().as_u16();
let bytes = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(RefreshError::Unauthorized {
status,
description: "GitHub token rejected at copilot-token exchange.".to_owned(),
});
}
if status == 200 {
return match serde_json::from_slice::<CopilotTokenResponse>(&bytes) {
Ok(parsed) => match copilot_auth(parsed, github_token) {
Ok(auth) => {
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token re-minted");
Ok(auth)
}
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("{e}"),
}),
},
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed copilot token payload: {e}"),
}),
};
}
let description = format!("copilot-token exchange failed (HTTP {status}).");
if RETRYABLE_EXCHANGE_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use kigi_models::COPILOT_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A COPILOT_OAUTH_CONFIG pointed at a mock server for both stages.
fn mock_cfg(host: &'static str, exchange: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
token_host: host,
copilot_exchange: Some((exchange, "/copilot_internal/v2/token")),
..COPILOT_OAUTH_CONFIG
}
}
/// GitHub's device poll returns pending errors in a 200 body — mapped to
/// Pending (authorization_pending / slow_down) and Expired (expired_token),
/// never mis-read as a token.
#[tokio::test]
async fn github_device_poll_maps_200_body_errors() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.and(body_string_contains("grant_type=urn"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(
matches!(result, DevicePollResult::Pending { error, .. } if error == "authorization_pending")
);
}
#[tokio::test]
async fn github_device_poll_expired_restarts() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "expired_token" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(matches!(result, DevicePollResult::Expired));
}
/// A successful poll yields the DURABLE github token as `key` with NO
/// refresh token / expiry (the copilot exchange finalizes it next).
#[tokio::test]
async fn github_device_poll_success_is_bare_github_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })),
)
.mount(&server)
.await;
let DevicePollResult::Success(auth) = poll_github_device_token(&mock_cfg(host, host), "d")
.await
.unwrap()
else {
panic!("expected success");
};
assert_eq!(auth.key, "gho_github_tok");
assert_eq!(
auth.refresh_token, None,
"github token is not a refresh grant"
);
assert_eq!(auth.expires_at, None, "the github token is long-lived");
}
/// The Stage-2 exchange rides the github Bearer + editor headers and maps
/// `{token, expires_at}` onto `key=copilot`, `refresh_token=github`, with a
/// future `expires_at`.
#[tokio::test]
async fn copilot_exchange_maps_token_and_persists_github_as_refresh() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.and(header("Editor-Version", "vscode/1.107.0"))
.and(header("Copilot-Integration-Id", "vscode-chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "tid=abc;copilot-tok", "expires_at": future }),
))
.expect(1)
.mount(&server)
.await;
let auth = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "tid=abc;copilot-tok", "key = copilot token");
assert_eq!(
auth.refresh_token.as_deref(),
Some("gho_github_tok"),
"the durable github token is persisted as refresh_token"
);
assert!(
auth.expires_at.is_some_and(|e| e > Utc::now()),
"copilot expiry must be in the future"
);
}
/// The copilot re-mint (refresh) re-exchanges the github token for a NEW
/// copilot token, keeping the github token as refresh_token.
#[tokio::test]
async fn copilot_remint_returns_new_copilot_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok-2", "expires_at": future }),
))
.mount(&server)
.await;
let auth = remint_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "copilot-tok-2");
assert_eq!(auth.refresh_token.as_deref(), Some("gho_github_tok"));
}
/// A 401 at the exchange (github token revoked) fails fast as Unauthorized
/// (drives the manager's permanent-failure / re-login path).
#[tokio::test]
async fn copilot_remint_401_is_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = remint_copilot_token(&mock_cfg(host, host), "dead-github-tok")
.await
.unwrap_err();
assert!(
matches!(err, RefreshError::Unauthorized { status: 401, .. }),
"got {err:?}"
);
}
/// FAIL-FAST: an out-of-range `expires_at` is rejected rather than silently
/// degrading to a long default TTL (which would 401 on the wire ~30 min in).
#[tokio::test]
async fn copilot_exchange_rejects_out_of_range_expiry() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok", "expires_at": i64::MAX }),
))
.mount(&server)
.await;
let err = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap_err();
assert!(
format!("{err}").contains("out-of-range expires_at"),
"got {err}"
);
}
}
@@ -5,6 +5,7 @@ pub(crate) mod device;
pub mod device_code;
pub mod error;
mod flow;
pub(crate) mod github_copilot;
pub(crate) mod kimi_oauth;
pub(crate) mod manager;
mod model;
@@ -147,6 +147,43 @@ mod tests {
.expect("claude-pro-max carries an OAuthConfig")
}
fn copilot_oauth() -> &'static kigi_models::OAuthConfig {
kigi_models::PlatformId::GithubCopilot
.oauth()
.expect("github-copilot carries an OAuthConfig")
}
/// A `github-copilot/<model>` turn resolves to the process-global pooled
/// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the
/// primary Kimi manager — the same leak-safe routing as xai-grok /
/// claude-pro-max, and a DISTINCT pool entry from either.
#[tokio::test]
async fn github_copilot_model_resolves_to_its_own_manager_not_kimi() {
let (_kd, kimi) = primary_with_token("kimi-tok");
let home = tempfile::tempdir().unwrap();
let resolved = manager_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi))
.expect("github-copilot model resolves to its pooled manager");
assert!(
!Arc::ptr_eq(&resolved, &kimi),
"github-copilot must NOT resolve to the Kimi manager"
);
assert!(
Arc::ptr_eq(&resolved, &global_manager_for(home.path(), copilot_oauth())),
"github-copilot must resolve to its OWN process-global pooled manager"
);
assert!(
!Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())),
"github-copilot and claude-pro-max must not share a pooled manager"
);
// Fail-fast: even with a Kimi primary, a copilot turn never yields the
// Kimi bearer — it draws from the copilot pool (its own token, or None).
assert_ne!(
session_key_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi)),
Some("kimi-tok".to_string()),
"a github-copilot model must never receive the primary Kimi token"
);
}
/// A `claude-pro-max/<model>` turn resolves to the process-global pooled
/// claude-pro-max manager (its OWN `oauth/claude-pro-max` scope), NEVER the
/// primary Kimi manager — the same leak-safe routing as xai-grok, and a
@@ -14,7 +14,7 @@ use kigi_models::{OAuthConfig, OAuthTokenBody};
use crate::auth::error::RefreshTokenFailedReason;
use crate::auth::kimi_oauth::RefreshError;
use crate::auth::manager::RefreshReason;
use crate::auth::{oauth_device, oauth_pkce};
use crate::auth::{github_copilot, oauth_device, oauth_pkce};
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
@@ -105,11 +105,17 @@ impl TokenRefresher for GenericDeviceRefresher {
);
// Refresh over the provider's token-body encoding: xai's endpoint is
// form-encoded (device wire); Claude's is JSON (PKCE wire). Both return
// the same `Result<KimiAuth, RefreshError>`.
// form-encoded (device wire); Claude's is JSON (PKCE wire); GitHub
// Copilot's "refresh" is a copilot-token RE-MINT — a `GET
// copilot_internal/v2/token` bearing the durable github token (the
// `refresh_token` field here), NOT a refresh_token grant. All three
// return the same `Result<KimiAuth, RefreshError>`.
let wire_result = match self.cfg.token_body {
OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await,
OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await,
OAuthTokenBody::GithubCopilotExchange => {
github_copilot::remint_copilot_token(self.cfg, &refresh_token).await
}
};
match wire_result {
Ok(new_auth) => {
@@ -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,
@@ -47,6 +47,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
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,
@@ -39,6 +39,7 @@ pub fn test_sampler_config(
api_backend,
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
chat_compat: Default::default(),
extra_headers: extra_headers
.iter()
+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}"