malvin 0.2.7

Non-interactive research and coding agent
use crate::acp::AuthError;

use super::discover::resolve_npm_pi_entry;

pub fn ensure_npm_pi_authenticated(model: &str) -> Result<(), AuthError> {
    resolve_npm_pi_entry().map_err(AuthError)?;
    let parsed = crate::model_id::parse_model_id(model).map_err(AuthError)?;
    let Some((provider, _)) = parsed.pi_provider_and_model() else {
        return Err(AuthError(format!(
            "pi model id must be `pi:<provider>/<model>` (got `{model}`)"
        )));
    };
    if crate::pi_sdk::is_provider_authenticated(provider) {
        return Ok(());
    }
    if !crate::pi_sdk::provider_known_in_rust_metadata(provider) {
        return Ok(());
    }
    Err(AuthError(format!(
        "pi backend is not authenticated for provider `{provider}`. Set a provider API key or store credentials for the npm Pi agent."
    )))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn with_fake_npm_pi_entry(body: impl FnOnce()) {
        let tmp = tempfile::tempdir().expect("tempdir");
        let entry = tmp.path().join("rpc-entry.js");
        std::fs::write(&entry, "// fake npm pi entry\n").expect("write entry");
        crate::acp::with_env(
            "MALVIN_PI",
            Some(entry.to_str().expect("utf8")),
            body,
        );
    }

    #[test]
    fn rejects_when_entry_missing() {
        let _lock = crate::test_utils::test_env_lock();
        crate::acp::with_env("MALVIN_PI", Some("/missing/npm-pi.js"), || {
            let err = ensure_npm_pi_authenticated("pi:openai/gpt-4o").expect_err("missing");
            assert!(err.0.contains("MALVIN_PI") || err.0.contains("missing"));
        });
    }

    #[test]
    fn allows_extension_providers_without_rust_auth_metadata() {
        let _lock = crate::test_utils::test_env_lock();
        with_fake_npm_pi_entry(|| {
            ensure_npm_pi_authenticated("pi:issue42-ext-provider/some-model")
                .expect("extension providers must not be false-rejected by rust-Pi auth gate");
        });
    }

    #[test]
    fn still_rejects_known_provider_without_credentials() {
        let _lock = crate::test_utils::test_env_lock();
        with_fake_npm_pi_entry(|| {
            crate::acp::with_env("OPENAI_API_KEY", None, || {
                if crate::pi_sdk::is_provider_authenticated("openai") {
                    return;
                }
                let err = ensure_npm_pi_authenticated("pi:openai/gpt-4o")
                    .expect_err("known provider without credentials must still fail fast");
                assert!(err.0.contains("openai"));
                assert!(err.0.contains("not authenticated"));
            });
        });
    }

    #[test]
    fn still_rejects_empty_env_key_builtins_without_credentials() {
        let _lock = crate::test_utils::test_env_lock();
        with_fake_npm_pi_entry(|| {
            assert!(crate::pi_sdk::provider_known_in_rust_metadata("openai-codex"));
            if crate::pi_sdk::is_provider_authenticated("openai-codex") {
                return;
            }
            let err = ensure_npm_pi_authenticated("pi:openai-codex/gpt-5")
                .expect_err("known empty-env-key builtins must still fail fast");
            assert!(err.0.contains("openai-codex"));
        });
    }
}