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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user