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.
302 lines
9.5 KiB
Rust
302 lines
9.5 KiB
Rust
//! Data APIs for `kigi models`. Clients own display.
|
|
|
|
use agent_client_protocol as acp;
|
|
use anyhow::Result;
|
|
use kigi_acp_lib::{AcpAgentTx, acp_send};
|
|
|
|
use crate::agent::config::Config as AgentConfig;
|
|
|
|
/// Status for the `kigi models` banner (display order ≠ sampling priority; see [`AuthStatus::resolve`]).
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum AuthStatus {
|
|
ApiKey,
|
|
/// Auth host from `kigi_ws_origin` (scheme stripped).
|
|
LoggedIn(String),
|
|
/// Catalog key of the first model with own `api_key`/`env_key`.
|
|
ModelCredentials(String),
|
|
DeploymentKey,
|
|
NotAuthenticated,
|
|
}
|
|
|
|
impl AuthStatus {
|
|
/// Banner status: env key → session → BYOK → deployment → none.
|
|
///
|
|
/// Differs from sampling (`resolve_credentials`: BYOK → session → env) so a
|
|
/// logged-in user sees the login host.
|
|
pub fn resolve(agent_config: &AgentConfig) -> Self {
|
|
if crate::agent::auth_method::has_xai_api_key_env() {
|
|
return Self::ApiKey;
|
|
}
|
|
if agent_config.create_auth_manager().current().is_some() {
|
|
let origin = kigi_env::oauth_host();
|
|
let host = origin
|
|
.strip_prefix("https://")
|
|
.or_else(|| origin.strip_prefix("http://"))
|
|
.unwrap_or(&origin);
|
|
return Self::LoggedIn(host.to_owned());
|
|
}
|
|
let models = crate::agent::config::resolve_model_list(
|
|
agent_config,
|
|
None,
|
|
&crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms),
|
|
);
|
|
if crate::agent::auth_method::should_advertise_xai_api_key(models.values())
|
|
&& let Some(name) = models
|
|
.iter()
|
|
.find_map(|(name, entry)| entry.has_own_credentials().then(|| name.clone()))
|
|
{
|
|
return Self::ModelCredentials(name);
|
|
}
|
|
if agent_config.endpoints.deployment_key.is_some() {
|
|
return Self::DeploymentKey;
|
|
}
|
|
Self::NotAuthenticated
|
|
}
|
|
}
|
|
|
|
/// Fetch model state (available models + default) over an ACP channel.
|
|
pub async fn list_models(
|
|
acp_tx: &AcpAgentTx,
|
|
client_type: &str,
|
|
client_version: &str,
|
|
) -> Result<acp::SessionModelState> {
|
|
let init_resp: acp::InitializeResponse = acp_send(
|
|
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
|
|
.client_capabilities(
|
|
acp::ClientCapabilities::new()
|
|
.fs(acp::FileSystemCapabilities::new())
|
|
.terminal(false),
|
|
)
|
|
.meta(
|
|
serde_json::json!({
|
|
"clientType": client_type,
|
|
"clientVersion": client_version,
|
|
})
|
|
.as_object()
|
|
.cloned(),
|
|
),
|
|
acp_tx,
|
|
)
|
|
.await?;
|
|
|
|
let model_state = init_resp
|
|
.meta
|
|
.and_then(|m| m.get("modelState").cloned())
|
|
.ok_or_else(|| anyhow::anyhow!("InitializeResponse missing modelState"))?;
|
|
let state: acp::SessionModelState = serde_json::from_value(model_state)
|
|
.map_err(|e| anyhow::anyhow!("Failed to parse modelState: {}", e))?;
|
|
|
|
Ok(state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::agent::auth_method::{
|
|
HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR,
|
|
};
|
|
use crate::agent::config::Config;
|
|
use crate::auth::{AuthMode, KimiAuth};
|
|
use kigi_test_support::EnvGuard;
|
|
use serial_test::serial;
|
|
|
|
/// Isolate process-global auth sources that `AuthStatus::resolve` consults.
|
|
///
|
|
/// Uses `KIGI_AUTH_PATH` (not `KIGI_SHARE_DIR`) so a OnceLock-cached real home
|
|
/// with `auth.json` cannot leak into these tests.
|
|
fn isolate_auth_sources() -> (tempfile::TempDir, [EnvGuard; 8]) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let auth_path = dir.path().join("no-auth.json");
|
|
let guards = [
|
|
EnvGuard::unset(HOUSE_API_KEY_ENV_VAR),
|
|
EnvGuard::unset(XAI_API_KEY_ENV_VAR),
|
|
EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR),
|
|
EnvGuard::unset("KIGI_AUTH"),
|
|
EnvGuard::set("KIGI_AUTH_PATH", auth_path.to_str().unwrap()),
|
|
EnvGuard::unset("KIGI_DEPLOYMENT_KEY"),
|
|
EnvGuard::unset("KIGI_WS_ORIGIN"),
|
|
EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH"),
|
|
];
|
|
(dir, guards)
|
|
}
|
|
|
|
fn byok_and_deployment_toml(model_id: &str) -> String {
|
|
format!(
|
|
r#"
|
|
[endpoints]
|
|
deployment_key = "deploy-key"
|
|
|
|
[model."{model_id}"]
|
|
model = "{model_id}"
|
|
api_key = "sk-byok"
|
|
"#
|
|
)
|
|
}
|
|
|
|
fn config_from_toml(toml_src: &str) -> Config {
|
|
let toml: toml::Value = toml::from_str(toml_src).unwrap();
|
|
Config::new_from_toml_cfg(&toml).expect("config should parse")
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_api_key_env() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-test-key");
|
|
assert_eq!(AuthStatus::resolve(&Config::default()), AuthStatus::ApiKey);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_legacy_api_key_env() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let _key = EnvGuard::set(LEGACY_XAI_API_KEY_ENV_VAR, "legacy-key");
|
|
assert_eq!(AuthStatus::resolve(&Config::default()), AuthStatus::ApiKey);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_oauth_session() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let token = KimiAuth {
|
|
key: "session-token".into(),
|
|
auth_mode: AuthMode::OAuth,
|
|
..KimiAuth::test_default()
|
|
};
|
|
let json = serde_json::to_string(&token).unwrap();
|
|
let _auth = EnvGuard::set("KIGI_AUTH", &json);
|
|
|
|
assert_eq!(
|
|
AuthStatus::resolve(&Config::default()),
|
|
AuthStatus::LoggedIn("auth.kimi.com".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_model_api_key_byok() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let dm = crate::models::default_model();
|
|
let cfg = config_from_toml(&format!(
|
|
r#"
|
|
[model."{dm}"]
|
|
model = "{dm}"
|
|
api_key = "sk-byok-inline"
|
|
"#
|
|
));
|
|
assert_eq!(
|
|
AuthStatus::resolve(&cfg),
|
|
AuthStatus::ModelCredentials(dm.to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_model_env_key_byok() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
const TEST_ENV: &str = "TEST_AUTH_STATUS_BYOK_ENV_KEY";
|
|
let dm = crate::models::default_model();
|
|
let cfg = config_from_toml(&format!(
|
|
r#"
|
|
[model."{dm}"]
|
|
model = "{dm}"
|
|
env_key = "{TEST_ENV}"
|
|
"#
|
|
));
|
|
|
|
{
|
|
let _unset = EnvGuard::unset(TEST_ENV);
|
|
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::NotAuthenticated);
|
|
}
|
|
{
|
|
let _set = EnvGuard::set(TEST_ENV, "secret-token");
|
|
assert_eq!(
|
|
AuthStatus::resolve(&cfg),
|
|
AuthStatus::ModelCredentials(dm.to_owned())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_deployment_key() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let mut cfg = Config::default();
|
|
cfg.endpoints.deployment_key = Some("deploy-key".into());
|
|
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::DeploymentKey);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_not_authenticated() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
assert_eq!(
|
|
AuthStatus::resolve(&Config::default()),
|
|
AuthStatus::NotAuthenticated
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_priority_api_key_over_byok_and_deployment() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "xai-test-key");
|
|
let dm = crate::models::default_model();
|
|
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
|
assert_eq!(AuthStatus::resolve(&cfg), AuthStatus::ApiKey);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_priority_session_over_byok_and_deployment() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let token = KimiAuth {
|
|
key: "session-token".into(),
|
|
auth_mode: AuthMode::OAuth,
|
|
..KimiAuth::test_default()
|
|
};
|
|
let json = serde_json::to_string(&token).unwrap();
|
|
let _auth = EnvGuard::set("KIGI_AUTH", &json);
|
|
|
|
let dm = crate::models::default_model();
|
|
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
|
assert_eq!(
|
|
AuthStatus::resolve(&cfg),
|
|
AuthStatus::LoggedIn("auth.kimi.com".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_priority_byok_over_deployment() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let dm = crate::models::default_model();
|
|
let cfg = config_from_toml(&byok_and_deployment_toml(dm));
|
|
assert_eq!(
|
|
AuthStatus::resolve(&cfg),
|
|
AuthStatus::ModelCredentials(dm.to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn resolve_model_credentials_uses_first_catalog_key() {
|
|
let (_dir, _g) = isolate_auth_sources();
|
|
let cfg = config_from_toml(
|
|
r#"
|
|
[model."my-openai"]
|
|
model = "gpt-4o"
|
|
api_key = "sk-first"
|
|
|
|
[model."my-anthropic"]
|
|
model = "claude"
|
|
api_key = "sk-second"
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
AuthStatus::resolve(&cfg),
|
|
AuthStatus::ModelCredentials("my-openai".to_owned())
|
|
);
|
|
}
|
|
}
|