feat(providers): add xAI (Grok) + migrate house BYOK env to KIGI_API_KEY

13th provider (16th registry variant). Also reconciles a naming collision
the matrix flagged: this fork is house-branded "xai" (cf. xai.dev metadata,
KIGI_CODE_XAI_API_KEY legacy env), so XAI_API_KEY + method xai.api_key were
the GENERIC house BYOK, not x.ai/Grok. The provider table wants xai/XAI_API_KEY
for Grok.

Resolution (user-approved): XAI_API_KEY now keys the x.ai/Grok provider; the
house BYOK primary env moves to KIGI_API_KEY, keeping XAI_API_KEY and
KIGI_CODE_XAI_API_KEY as back-compat fallbacks (read_xai_api_key_env checks
KIGI_API_KEY first). The xai.api_key method id is unchanged (persisted-session
compat); the platform method id is the bare "xai", distinct from it.

xAI spec: api.x.ai/v1, Bearer, OpenAI listing + ChatCompletions, Passthrough
(docs confirm stream_options.include_usage accepted). /v1/models is minimal
(ids only) and requires auth, so it doubles as the key validator (401 on bad
key, no override) and metadata comes from models.dev enrichment. Live ids
match the models.dev "xai" keys byte-for-byte, so restrict_to_enriched keeps
the 5 tool-calling chat models (grok-4.5/4.3/4.20-0309-*/build-0.1) and drops
the grok-imagine-* generators + the non-tool multi-agent model. Snapshot
regenerated to include the xai provider (was stale; gen script already listed
it in TARGETS).

Env migration is comprehensive to avoid keying the xai platform (which would
trigger a live api.x.ai fetch) or leaving house-key reads stranded: routed
the trace CLI resolver + acp_agent/auth.json bridge + paste-key ext handler
through the new primary; moved all leader/pager/e2e harness setters to
KIGI_API_KEY; made every house-key isolation test unset KIGI_API_KEY too;
updated user-facing hints to name KIGI_API_KEY.

Tests: e2e proves enrichment-supplied context (wire carries none), non-vacuous
tool_call restriction, bare-id round-trip under xai/, Passthrough; validation
tests hit /models (401 reject, 200 accept); house_env_var_takes_precedence_over_xai
pins the new precedence. Registry at 16; picker 17 rows; 4 auth arrays + xai.

Review (16 findings, all fixed): caught a missed else-branch env clear in the
paste-key handler (would leak the house key past a clear) and a non-hermetic
credential-priority test; both fixed.
This commit is contained in:
2026-07-21 16:23:08 -04:00
parent 193d16f6d5
commit ebf11057f8
25 changed files with 297 additions and 56 deletions
+42 -2
View File
@@ -597,6 +597,41 @@ const VERCEL_SPEC: PlatformSpec = PlatformSpec {
restrict_to_enriched: true,
};
pub const XAI_BASE_URL_ENV: &str = "KIGI_XAI_BASE_URL";
const XAI_SPEC: PlatformSpec = PlatformSpec {
id: "xai",
display_name: "xAI (Grok)",
base_url: BaseUrlSource::EnvOr {
env: XAI_BASE_URL_ENV,
default: "https://api.x.ai/v1",
},
uses_oauth: false,
allowed_model_prefixes: None,
// NOTE: `XAI_API_KEY` is ALSO read as a legacy fallback by the house BYOK
// path (`read_xai_api_key_env`), whose primary env is now `KIGI_API_KEY`.
// Here it is the x.ai/Grok provider key (its canonical ecosystem name).
api_key_envs: &["XAI_API_KEY"],
vendor: "xAI",
console_host: Some("console.x.ai"),
login_label: Some("xAI (Grok) (API key)"),
models_dev_id: Some("xai"),
// /v1/models is minimal (ids only; rich metadata lives on the non-standard
// /v1/language-models), so take context/limits from models.dev enrichment.
// The /v1/models ids match the models.dev "xai" keys byte-for-byte
// (grok-4.5, grok-4.20-0309-reasoning, ...); restrict to tool-calling chat
// models to drop the grok-imagine-* image/video generators.
wire_serves_metadata: false,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
chat_compat: PlatformChatCompat::Passthrough,
key_header: PlatformKeyHeader::Bearer,
// /v1/models requires auth (401 without a key), so it doubles as the key
// validator; no separate public endpoint needed.
key_validation_path: None,
strip_listing_id_prefix: None,
restrict_to_enriched: true,
};
/// 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)]
@@ -631,12 +666,14 @@ pub enum PlatformId {
Nvidia,
/// Vercel AI Gateway (API key, wire-listed with models.dev enrichment).
Vercel,
/// xAI Grok platform (API key, OpenAI-compatible ChatCompletions).
Xai,
}
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; 15] = [
pub const ALL: [PlatformId; 16] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
@@ -652,6 +689,7 @@ impl PlatformId {
Self::Cerebras,
Self::Nvidia,
Self::Vercel,
Self::Xai,
];
/// The registry row backing this platform (single source of per-platform
@@ -673,6 +711,7 @@ impl PlatformId {
Self::Cerebras => &CEREBRAS_SPEC,
Self::Nvidia => &NVIDIA_SPEC,
Self::Vercel => &VERCEL_SPEC,
Self::Xai => &XAI_SPEC,
}
}
@@ -1428,9 +1467,10 @@ mod tests {
PlatformId::Cerebras => 12,
PlatformId::Nvidia => 13,
PlatformId::Vercel => 14,
PlatformId::Xai => 15,
}
}
const VARIANT_COUNT: usize = 15; // update together with `ordinal`
const VARIANT_COUNT: usize = 16; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();