Files
Kigi-CLI/crates/codegen/kigi-shell/src/cli_models.rs
T
ZacharyZhang-NY c5ddaec71e Add per-provider auth.json keys; make auth methods registry-generic (P0b)
Platform API keys now live in auth.json under the platform-id scope (the
per-provider auth.json key contract), resolved env > auth.json > legacy
[platforms.*] config.toml (read-only fallback). The TUI login picker,
paste box, auth-method advertising, and authenticate handler are all
registry-generic: a new PlatformSpec row appears in the login UI and
authenticates with zero UI changes. Spec rows gained vendor/console_host/
login_label display fields (moonshot strings byte-identical, pinned by
tests).

Adversarial review caught that auth.json keys were validated at login but
never stamped onto catalog entries (completions would 401; restart lost
eager auth). Fixed red-green: resolve_model_list/resolve_model_catalog now
take a resolved PlatformApiKeys snapshot consumed by the credential-
stamping layer (auth.json beats stale config.toml, matching the login
validator), with production callers resolving fresh per catalog build.
Also from review: the new auth.json writer takes the manager's cross-
process flock (bounded retry — an unlocked RMW racing a token refresh
could revert a rotated refresh token); the oauth-401 wiremock test is
hermetic (KIGI_SHARE_DIR tempdir; it could read a dev's real auth.json
and hit live moonshot); cli_models resolves real keys; auth.json is read
once per registry sweep; caller-less lock_config_writes deleted; catalog
resolvers tightened to pub(crate); stale config.toml doc comments and the
no-credentials error copy updated.
2026-07-21 01:18:46 -04:00

299 lines
9.4 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::{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; 7]) {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("no-auth.json");
let guards = [
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())
);
}
}