mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add [web] config section and arc doctor command
Move auth config under [web.auth] in arc.toml to group web-specific settings together. Add WebConfig with url field (default localhost:5173). Add `arc doctor` command with checks for config, API, web, LLM providers, Brave Search, sandbox, and GitHub App. Extract Provider::api_key_env_vars and has_api_key to deduplicate validation logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9e30c7ae0
commit
84b2003a5a
10 changed files with 1118 additions and 30 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -172,8 +172,10 @@ dependencies = [
|
|||
"arc-util",
|
||||
"arc-workflows",
|
||||
"assert_cmd",
|
||||
"bollard",
|
||||
"chrono",
|
||||
"clap",
|
||||
"daytona-sdk",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"httpmock",
|
||||
|
|
|
|||
|
|
@ -19,8 +19,13 @@ interface GitConfig {
|
|||
client_id: string | null;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
interface WebConfig {
|
||||
url: string;
|
||||
auth: AuthConfig;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
web: WebConfig;
|
||||
api: ApiConfig;
|
||||
git: GitConfig;
|
||||
}
|
||||
|
|
@ -30,6 +35,11 @@ const AUTH_DEFAULTS: AuthConfig = {
|
|||
allowed_usernames: [],
|
||||
};
|
||||
|
||||
const WEB_DEFAULTS: WebConfig = {
|
||||
url: "http://localhost:5173",
|
||||
auth: AUTH_DEFAULTS,
|
||||
};
|
||||
|
||||
const API_DEFAULTS: ApiConfig = {
|
||||
base_url: "http://localhost:3000",
|
||||
authentication_strategy: "jwt",
|
||||
|
|
@ -53,12 +63,17 @@ function loadAppConfig(): AppConfig {
|
|||
// File doesn't exist or is unreadable — use defaults
|
||||
}
|
||||
|
||||
const rawAuth = (raw.auth ?? {}) as Partial<AuthConfig>;
|
||||
const rawWeb = (raw.web ?? {}) as Record<string, unknown>;
|
||||
const rawWebAuth = (rawWeb.auth ?? {}) as Partial<AuthConfig>;
|
||||
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
|
||||
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
|
||||
|
||||
return {
|
||||
auth: { ...AUTH_DEFAULTS, ...rawAuth },
|
||||
web: {
|
||||
...WEB_DEFAULTS,
|
||||
url: (rawWeb.url as string) ?? WEB_DEFAULTS.url,
|
||||
auth: { ...AUTH_DEFAULTS, ...rawWebAuth },
|
||||
},
|
||||
api: { ...API_DEFAULTS, ...rawApi },
|
||||
git: { ...GIT_DEFAULTS, ...rawGit },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
}>;
|
||||
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? "";
|
||||
|
||||
const { allowed_usernames } = getAppConfig().auth;
|
||||
const { allowed_usernames } = getAppConfig().web.auth;
|
||||
if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) {
|
||||
throw redirect("/auth/login?error=unauthorized");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,20 +191,6 @@ fn build_profile(
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_api_key(provider: Provider) -> bool {
|
||||
match provider {
|
||||
Provider::Anthropic => std::env::var("ANTHROPIC_API_KEY").is_ok(),
|
||||
Provider::OpenAi => std::env::var("OPENAI_API_KEY").is_ok(),
|
||||
Provider::Gemini => {
|
||||
std::env::var("GEMINI_API_KEY").is_ok() || std::env::var("GOOGLE_API_KEY").is_ok()
|
||||
}
|
||||
Provider::Kimi => std::env::var("KIMI_API_KEY").is_ok(),
|
||||
Provider::Zai => std::env::var("ZAI_API_KEY").is_ok(),
|
||||
Provider::Minimax => std::env::var("MINIMAX_API_KEY").is_ok(),
|
||||
Provider::Inception => std::env::var("INCEPTION_API_KEY").is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String {
|
||||
let cwd_prefix = if cwd.ends_with('/') {
|
||||
cwd.to_string()
|
||||
|
|
@ -359,7 +345,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
|
|||
.map_err(|e: String| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// Validate provider API key
|
||||
if !validate_api_key(provider) {
|
||||
if !provider.has_api_key() {
|
||||
anyhow::bail!("API key not set for provider '{provider}'");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,11 +63,32 @@ pub struct GitConfig {
|
|||
pub client_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct WebConfig {
|
||||
#[serde(default = "default_web_url")]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub auth: AuthConfig,
|
||||
}
|
||||
|
||||
fn default_web_url() -> String {
|
||||
"http://localhost:5173".to_string()
|
||||
}
|
||||
|
||||
impl Default for WebConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: default_web_url(),
|
||||
auth: AuthConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub data_dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub auth: AuthConfig,
|
||||
pub web: WebConfig,
|
||||
#[serde(default)]
|
||||
pub api: ApiConfig,
|
||||
#[serde(default)]
|
||||
|
|
@ -141,7 +162,10 @@ mod tests {
|
|||
#[test]
|
||||
fn parse_full_config() {
|
||||
let toml = r#"
|
||||
[auth]
|
||||
[web]
|
||||
url = "https://arc.example.com"
|
||||
|
||||
[web.auth]
|
||||
provider = "github"
|
||||
allowed_usernames = ["brynary", "alice"]
|
||||
|
||||
|
|
@ -155,8 +179,9 @@ app_id = "12345"
|
|||
client_id = "Iv1.abc123"
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.auth.provider, AuthProvider::Github);
|
||||
assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]);
|
||||
assert_eq!(config.web.url, "https://arc.example.com");
|
||||
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
||||
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
|
||||
assert_eq!(config.api.base_url, "http://example.com:8080");
|
||||
assert_eq!(
|
||||
config.api.authentication_strategy,
|
||||
|
|
@ -168,11 +193,12 @@ client_id = "Iv1.abc123"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_auth_defaults() {
|
||||
fn parse_web_defaults() {
|
||||
let toml = "";
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.auth.provider, AuthProvider::Github);
|
||||
assert!(config.auth.allowed_usernames.is_empty());
|
||||
assert_eq!(config.web.url, "http://localhost:5173");
|
||||
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
||||
assert!(config.web.auth.allowed_usernames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -235,7 +261,7 @@ repo_url = "https://github.com/org/repo"
|
|||
#[test]
|
||||
fn parse_config_server_and_run_defaults_together() {
|
||||
let toml = r#"
|
||||
[auth]
|
||||
[web.auth]
|
||||
provider = "github"
|
||||
|
||||
[git]
|
||||
|
|
@ -246,7 +272,7 @@ app_id = "123"
|
|||
model = "gpt-4"
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.auth.provider, AuthProvider::Github);
|
||||
assert_eq!(config.web.auth.provider, AuthProvider::Github);
|
||||
assert_eq!(config.git.app_id.as_deref(), Some("123"));
|
||||
let llm = config.run_defaults.llm.unwrap();
|
||||
assert_eq!(llm.model.as_deref(), Some("gpt-4"));
|
||||
|
|
@ -255,14 +281,14 @@ model = "gpt-4"
|
|||
#[test]
|
||||
fn parse_insecure_disabled_values() {
|
||||
let toml = r#"
|
||||
[auth]
|
||||
[web.auth]
|
||||
provider = "insecure_disabled"
|
||||
|
||||
[api]
|
||||
authentication_strategy = "insecure_disabled"
|
||||
"#;
|
||||
let config: ServerConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled);
|
||||
assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled);
|
||||
assert_eq!(
|
||||
config.api.authentication_strategy,
|
||||
ApiAuthenticationStrategy::InsecureDisabled
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ arc-agent = { path = "../arc-agent" }
|
|||
arc-workflows = { path = "../arc-workflows" }
|
||||
arc-api = { path = "../arc-api" }
|
||||
arc-util = { path = "../arc-util" }
|
||||
bollard.workspace = true
|
||||
clap.workspace = true
|
||||
daytona-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
tokio.workspace = true
|
||||
|
|
|
|||
915
crates/arc-cli/src/doctor.rs
Normal file
915
crates/arc-cli/src/doctor.rs
Normal file
|
|
@ -0,0 +1,915 @@
|
|||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use arc_api::server_config::{ApiAuthenticationStrategy, AuthProvider};
|
||||
use arc_llm::provider::Provider;
|
||||
use arc_util::terminal::Styles;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CheckStatus {
|
||||
Pass,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckDetail {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CheckResult {
|
||||
pub name: String,
|
||||
pub status: CheckStatus,
|
||||
pub summary: String,
|
||||
pub details: Vec<CheckDetail>,
|
||||
pub remediation: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DoctorReport {
|
||||
pub checks: Vec<CheckResult>,
|
||||
}
|
||||
|
||||
impl DoctorReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.checks
|
||||
.iter()
|
||||
.any(|c| c.status == CheckStatus::Error)
|
||||
}
|
||||
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.checks
|
||||
.iter()
|
||||
.filter(|c| matches!(c.status, CheckStatus::Warning | CheckStatus::Error))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn render(&self, s: &Styles, verbose: bool) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
writeln!(out, "{b}Arc Doctor{r}", b = s.bold, r = s.reset).unwrap();
|
||||
writeln!(out).unwrap();
|
||||
|
||||
for check in &self.checks {
|
||||
let (icon, color) = match check.status {
|
||||
CheckStatus::Pass => ("[✓]", s.green),
|
||||
CheckStatus::Warning => ("[!]", s.yellow),
|
||||
CheckStatus::Error => ("[✗]", s.red),
|
||||
};
|
||||
|
||||
writeln!(
|
||||
out,
|
||||
" {color}{icon}{r} {b}{name}{r} ({summary})",
|
||||
color = color,
|
||||
r = s.reset,
|
||||
b = s.bold,
|
||||
name = check.name,
|
||||
summary = check.summary,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if verbose {
|
||||
for detail in &check.details {
|
||||
writeln!(out, " • {}", detail.text).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let issues = self.issue_count();
|
||||
writeln!(out).unwrap();
|
||||
|
||||
if issues == 0 {
|
||||
writeln!(out, "All checks passed.").unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
out,
|
||||
"Doctor found issues in {issues} {}.",
|
||||
if issues == 1 { "category" } else { "categories" }
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let errors: Vec<_> = self
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Error)
|
||||
.collect();
|
||||
if !errors.is_empty() {
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "{b}Errors:{r}", b = s.bold, r = s.reset).unwrap();
|
||||
for check in &errors {
|
||||
write!(out, " • {}", check.name).unwrap();
|
||||
if let Some(ref rem) = check.remediation {
|
||||
write!(out, " — {rem}").unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let warnings: Vec<_> = self
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|c| c.status == CheckStatus::Warning)
|
||||
.collect();
|
||||
if !warnings.is_empty() {
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "{b}Warnings:{r}", b = s.bold, r = s.reset).unwrap();
|
||||
for check in &warnings {
|
||||
write!(out, " • {}", check.name).unwrap();
|
||||
if let Some(ref rem) = check.remediation {
|
||||
write!(out, " — {rem}").unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check functions (pure, testable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn check_config(path: Option<PathBuf>) -> CheckResult {
|
||||
match path {
|
||||
Some(p) => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: p.display().to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: format!("Loaded from {}", p.display()),
|
||||
}],
|
||||
remediation: None,
|
||||
},
|
||||
None => CheckResult {
|
||||
name: "Configuration".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "no config file found".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "Create ~/.arc/arc.toml to configure Arc".to_string(),
|
||||
}],
|
||||
remediation: Some("Create ~/.arc/arc.toml".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult {
|
||||
let configured: Vec<_> = statuses.iter().filter(|(_, set)| *set).collect();
|
||||
let total = statuses.len();
|
||||
let count = configured.len();
|
||||
|
||||
let details: Vec<CheckDetail> = statuses
|
||||
.iter()
|
||||
.map(|(provider, set)| {
|
||||
let env_vars = provider.api_key_env_vars().join(" or ");
|
||||
let status_text = if *set { "set" } else { "not set" };
|
||||
CheckDetail {
|
||||
text: format!("{provider} ({env_vars}): {status_text}"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if count == 0 {
|
||||
CheckResult {
|
||||
name: "LLM providers".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: format!("{count} of {total} configured"),
|
||||
details,
|
||||
remediation: Some("Set at least one provider API key".to_string()),
|
||||
}
|
||||
} else {
|
||||
CheckResult {
|
||||
name: "LLM providers".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: format!("{count} of {total} configured"),
|
||||
details,
|
||||
remediation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_brave_search(api_key_set: bool) -> CheckResult {
|
||||
if api_key_set {
|
||||
CheckResult {
|
||||
name: "Brave Search".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "API key set".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "BRAVE_SEARCH_API_KEY is set".to_string(),
|
||||
}],
|
||||
remediation: None,
|
||||
}
|
||||
} else {
|
||||
CheckResult {
|
||||
name: "Brave Search".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "not configured".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "BRAVE_SEARCH_API_KEY is not set".to_string(),
|
||||
}],
|
||||
remediation: Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SandboxStatus {
|
||||
pub daytona: Option<Result<(), String>>,
|
||||
pub docker: Option<Result<(), String>>,
|
||||
}
|
||||
|
||||
pub fn check_sandbox(status: &SandboxStatus) -> CheckResult {
|
||||
let mut available = Vec::new();
|
||||
let mut details = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
match &status.daytona {
|
||||
Some(Ok(())) => {
|
||||
available.push("Daytona");
|
||||
details.push(CheckDetail {
|
||||
text: "Daytona (DAYTONA_API_KEY): available".to_string(),
|
||||
});
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
errors.push(format!("Daytona: {e}"));
|
||||
details.push(CheckDetail {
|
||||
text: format!("Daytona (DAYTONA_API_KEY): error — {e}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
details.push(CheckDetail {
|
||||
text: "Daytona (DAYTONA_API_KEY): not configured".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match &status.docker {
|
||||
Some(Ok(())) => {
|
||||
available.push("Docker");
|
||||
details.push(CheckDetail {
|
||||
text: "Docker: available".to_string(),
|
||||
});
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
errors.push(format!("Docker: {e}"));
|
||||
details.push(CheckDetail {
|
||||
text: format!("Docker: error — {e}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
details.push(CheckDetail {
|
||||
text: "Docker: not available".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
CheckResult {
|
||||
name: "Sandbox".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: errors.join("; "),
|
||||
details,
|
||||
remediation: Some("Fix sandbox configuration errors".to_string()),
|
||||
}
|
||||
} else if available.is_empty() {
|
||||
CheckResult {
|
||||
name: "Sandbox".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "no sandbox available".to_string(),
|
||||
details,
|
||||
remediation: Some(
|
||||
"Install Docker or set DAYTONA_API_KEY to enable sandboxed execution".to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
CheckResult {
|
||||
name: "Sandbox".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: format!("{} available", available.join(" + ")),
|
||||
details,
|
||||
remediation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GithubAppStatus {
|
||||
pub app_id: bool,
|
||||
pub client_id: bool,
|
||||
pub client_secret: bool,
|
||||
pub webhook_secret: bool,
|
||||
pub private_key: bool,
|
||||
}
|
||||
|
||||
impl GithubAppStatus {
|
||||
fn all_set(&self) -> bool {
|
||||
self.app_id
|
||||
&& self.client_id
|
||||
&& self.client_secret
|
||||
&& self.webhook_secret
|
||||
&& self.private_key
|
||||
}
|
||||
|
||||
fn none_set(&self) -> bool {
|
||||
!self.app_id
|
||||
&& !self.client_id
|
||||
&& !self.client_secret
|
||||
&& !self.webhook_secret
|
||||
&& !self.private_key
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_github_app(status: &GithubAppStatus) -> CheckResult {
|
||||
let fields = [
|
||||
("git.app_id", status.app_id),
|
||||
("git.client_id", status.client_id),
|
||||
("GITHUB_APP_CLIENT_SECRET", status.client_secret),
|
||||
("GITHUB_APP_WEBHOOK_SECRET", status.webhook_secret),
|
||||
("GITHUB_APP_PRIVATE_KEY", status.private_key),
|
||||
];
|
||||
|
||||
let details: Vec<CheckDetail> = fields
|
||||
.iter()
|
||||
.map(|(name, set)| CheckDetail {
|
||||
text: format!("{name}: {}", if *set { "set" } else { "not set" }),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if status.all_set() {
|
||||
CheckResult {
|
||||
name: "GitHub App".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "fully configured".to_string(),
|
||||
details,
|
||||
remediation: None,
|
||||
}
|
||||
} else if status.none_set() {
|
||||
CheckResult {
|
||||
name: "GitHub App".to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "not configured".to_string(),
|
||||
details,
|
||||
remediation: Some(
|
||||
"Configure GitHub App in arc.toml and set env vars to enable GitHub integration"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let missing: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|(_, set)| !set)
|
||||
.map(|(name, _)| *name)
|
||||
.collect();
|
||||
CheckResult {
|
||||
name: "GitHub App".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "partially configured".to_string(),
|
||||
details,
|
||||
remediation: Some(format!("Missing: {}", missing.join(", "))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiStatus {
|
||||
pub base_url: String,
|
||||
pub authentication_strategy: String,
|
||||
}
|
||||
|
||||
pub fn check_api(status: &ApiStatus) -> CheckResult {
|
||||
CheckResult {
|
||||
name: "Arc API".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: status.base_url.clone(),
|
||||
details: vec![
|
||||
CheckDetail {
|
||||
text: format!("Base URL: {}", status.base_url),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Authentication: {}", status.authentication_strategy),
|
||||
},
|
||||
],
|
||||
remediation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebStatus {
|
||||
pub url: String,
|
||||
pub auth_provider: String,
|
||||
pub allowed_usernames_count: usize,
|
||||
}
|
||||
|
||||
pub fn check_web(status: &WebStatus) -> CheckResult {
|
||||
CheckResult {
|
||||
name: "Arc Web".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: status.url.clone(),
|
||||
details: vec![
|
||||
CheckDetail {
|
||||
text: format!("URL: {}", status.url),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Auth provider: {}", status.auth_provider),
|
||||
},
|
||||
CheckDetail {
|
||||
text: format!("Allowed usernames: {}", status.allowed_usernames_count),
|
||||
},
|
||||
],
|
||||
remediation: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator (does real I/O)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn probe_daytona() -> Option<Result<(), String>> {
|
||||
if std::env::var("DAYTONA_API_KEY").is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
daytona_sdk::Client::new()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
async fn probe_docker() -> Option<Result<(), String>> {
|
||||
let docker = bollard::Docker::connect_with_local_defaults()
|
||||
.map_err(|e| e.to_string())
|
||||
.ok()?;
|
||||
Some(docker.ping().await.map(|_| ()).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_doctor(verbose: bool) -> i32 {
|
||||
let styles = Styles::detect_stdout();
|
||||
|
||||
// Gather state
|
||||
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("arc.toml"));
|
||||
let config_exists = config_path
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.exists());
|
||||
|
||||
let llm_statuses: Vec<(Provider, bool)> = Provider::ALL
|
||||
.iter()
|
||||
.map(|p| (*p, p.has_api_key()))
|
||||
.collect();
|
||||
|
||||
let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok();
|
||||
|
||||
let server_config = arc_api::server_config::load_server_config()
|
||||
.unwrap_or_default();
|
||||
|
||||
let api_status = ApiStatus {
|
||||
base_url: server_config.api.base_url.clone(),
|
||||
authentication_strategy: match server_config.api.authentication_strategy {
|
||||
ApiAuthenticationStrategy::Jwt => "jwt".to_string(),
|
||||
ApiAuthenticationStrategy::InsecureDisabled => "insecure_disabled".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let web_status = WebStatus {
|
||||
url: server_config.web.url.clone(),
|
||||
auth_provider: match server_config.web.auth.provider {
|
||||
AuthProvider::Github => "github".to_string(),
|
||||
AuthProvider::InsecureDisabled => "insecure_disabled".to_string(),
|
||||
},
|
||||
allowed_usernames_count: server_config.web.auth.allowed_usernames.len(),
|
||||
};
|
||||
|
||||
let github_status = GithubAppStatus {
|
||||
app_id: server_config.git.app_id.is_some(),
|
||||
client_id: server_config.git.client_id.is_some(),
|
||||
client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(),
|
||||
webhook_secret: std::env::var("GITHUB_APP_WEBHOOK_SECRET").is_ok(),
|
||||
private_key: std::env::var("GITHUB_APP_PRIVATE_KEY").is_ok(),
|
||||
};
|
||||
|
||||
// Probe sandboxes concurrently
|
||||
let (daytona_result, docker_result) = tokio::join!(probe_daytona(), probe_docker());
|
||||
let sandbox_status = SandboxStatus {
|
||||
daytona: daytona_result,
|
||||
docker: docker_result,
|
||||
};
|
||||
|
||||
// Run pure checks
|
||||
let report = DoctorReport {
|
||||
checks: vec![
|
||||
check_config(if config_exists { config_path } else { None }),
|
||||
check_api(&api_status),
|
||||
check_web(&web_status),
|
||||
check_llm_providers(&llm_statuses),
|
||||
check_brave_search(brave_key_set),
|
||||
check_sandbox(&sandbox_status),
|
||||
check_github_app(&github_status),
|
||||
],
|
||||
};
|
||||
|
||||
print!("{}", report.render(&styles, verbose));
|
||||
|
||||
if report.has_errors() { 1 } else { 0 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pass_check(name: &str) -> CheckResult {
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
summary: "all good".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "everything is fine".to_string(),
|
||||
}],
|
||||
remediation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn warning_check(name: &str) -> CheckResult {
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status: CheckStatus::Warning,
|
||||
summary: "not configured".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "missing something".to_string(),
|
||||
}],
|
||||
remediation: Some("fix it".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_check(name: &str) -> CheckResult {
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "broken".to_string(),
|
||||
details: vec![CheckDetail {
|
||||
text: "something is wrong".to_string(),
|
||||
}],
|
||||
remediation: Some("repair it".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// -- render: all-pass, no color --
|
||||
|
||||
#[test]
|
||||
fn render_all_pass_no_color() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("Test")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), false);
|
||||
assert!(out.contains("[✓]"));
|
||||
assert!(out.contains("All checks passed."));
|
||||
assert!(out.contains("Arc Doctor"));
|
||||
}
|
||||
|
||||
// -- render: warning footer --
|
||||
|
||||
#[test]
|
||||
fn render_warning_footer() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![warning_check("Optional")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), false);
|
||||
assert!(out.contains("[!]"));
|
||||
assert!(out.contains("Doctor found issues in 1 category."));
|
||||
assert!(out.contains("Warnings:"));
|
||||
assert!(out.contains("fix it"));
|
||||
}
|
||||
|
||||
// -- render: error footer --
|
||||
|
||||
#[test]
|
||||
fn render_error_footer() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![error_check("Broken")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), false);
|
||||
assert!(out.contains("[✗]"));
|
||||
assert!(out.contains("Errors:"));
|
||||
assert!(out.contains("repair it"));
|
||||
}
|
||||
|
||||
// -- render: verbose mode --
|
||||
|
||||
#[test]
|
||||
fn render_verbose_shows_details() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("Verbose")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), true);
|
||||
assert!(out.contains("•"));
|
||||
assert!(out.contains("everything is fine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_default_hides_details() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("Verbose")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), false);
|
||||
assert!(!out.contains("everything is fine"));
|
||||
}
|
||||
|
||||
// -- render: color --
|
||||
|
||||
#[test]
|
||||
fn render_color_pass_green() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("Color")],
|
||||
};
|
||||
let out = report.render(&Styles::new(true), false);
|
||||
assert!(out.contains("\x1b[32m")); // green
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_color_warning_yellow() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![warning_check("Color")],
|
||||
};
|
||||
let out = report.render(&Styles::new(true), false);
|
||||
assert!(out.contains("\x1b[33m")); // yellow
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_color_error_red() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![error_check("Color")],
|
||||
};
|
||||
let out = report.render(&Styles::new(true), false);
|
||||
assert!(out.contains("\x1b[31m")); // red
|
||||
}
|
||||
|
||||
// -- has_errors / issue_count --
|
||||
|
||||
#[test]
|
||||
fn has_errors_false_for_warnings_only() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("OK"), warning_check("Warn")],
|
||||
};
|
||||
assert!(!report.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_errors_true_when_error_present() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![pass_check("OK"), error_check("Broken")],
|
||||
};
|
||||
assert!(report.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_count_counts_warnings_and_errors() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![
|
||||
pass_check("OK"),
|
||||
warning_check("Warn"),
|
||||
error_check("Broken"),
|
||||
],
|
||||
};
|
||||
assert_eq!(report.issue_count(), 2);
|
||||
}
|
||||
|
||||
// -- check_config --
|
||||
|
||||
#[test]
|
||||
fn check_config_pass_with_path() {
|
||||
let result = check_config(Some(PathBuf::from("/home/user/.arc/arc.toml")));
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains(".arc/arc.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_config_warning_without_path() {
|
||||
let result = check_config(None);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.remediation.is_some());
|
||||
}
|
||||
|
||||
// -- check_llm_providers --
|
||||
|
||||
#[test]
|
||||
fn check_llm_all_configured() {
|
||||
let statuses: Vec<(Provider, bool)> =
|
||||
Provider::ALL.iter().map(|p| (*p, true)).collect();
|
||||
let result = check_llm_providers(&statuses);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("7 of 7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_llm_some_configured() {
|
||||
let mut statuses: Vec<(Provider, bool)> =
|
||||
Provider::ALL.iter().map(|p| (*p, false)).collect();
|
||||
statuses[0].1 = true; // Anthropic
|
||||
statuses[1].1 = true; // OpenAi
|
||||
statuses[2].1 = true; // Gemini
|
||||
statuses[3].1 = true; // Kimi
|
||||
statuses[4].1 = true; // Zai
|
||||
let result = check_llm_providers(&statuses);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("5 of 7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_llm_none_configured() {
|
||||
let statuses: Vec<(Provider, bool)> =
|
||||
Provider::ALL.iter().map(|p| (*p, false)).collect();
|
||||
let result = check_llm_providers(&statuses);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert!(result.summary.contains("0 of 7"));
|
||||
}
|
||||
|
||||
// -- check_brave_search --
|
||||
|
||||
#[test]
|
||||
fn check_brave_configured() {
|
||||
let result = check_brave_search(true);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_brave_not_configured() {
|
||||
let result = check_brave_search(false);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
assert!(result.remediation.is_some());
|
||||
}
|
||||
|
||||
// -- check_sandbox --
|
||||
|
||||
#[test]
|
||||
fn check_sandbox_daytona_ok() {
|
||||
let status = SandboxStatus {
|
||||
daytona: Some(Ok(())),
|
||||
docker: None,
|
||||
};
|
||||
let result = check_sandbox(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("Daytona available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_sandbox_docker_ok() {
|
||||
let status = SandboxStatus {
|
||||
daytona: None,
|
||||
docker: Some(Ok(())),
|
||||
};
|
||||
let result = check_sandbox(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("Docker available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_sandbox_both_ok() {
|
||||
let status = SandboxStatus {
|
||||
daytona: Some(Ok(())),
|
||||
docker: Some(Ok(())),
|
||||
};
|
||||
let result = check_sandbox(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert!(result.summary.contains("Daytona + Docker available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_sandbox_both_unavailable() {
|
||||
let status = SandboxStatus {
|
||||
daytona: None,
|
||||
docker: None,
|
||||
};
|
||||
let result = check_sandbox(&status);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_sandbox_configured_but_broken() {
|
||||
let status = SandboxStatus {
|
||||
daytona: Some(Err("connection refused".to_string())),
|
||||
docker: None,
|
||||
};
|
||||
let result = check_sandbox(&status);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
}
|
||||
|
||||
// -- check_github_app --
|
||||
|
||||
#[test]
|
||||
fn check_github_all_set() {
|
||||
let status = GithubAppStatus {
|
||||
app_id: true,
|
||||
client_id: true,
|
||||
client_secret: true,
|
||||
webhook_secret: true,
|
||||
private_key: true,
|
||||
};
|
||||
let result = check_github_app(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_github_none_set() {
|
||||
let status = GithubAppStatus {
|
||||
app_id: false,
|
||||
client_id: false,
|
||||
client_secret: false,
|
||||
webhook_secret: false,
|
||||
private_key: false,
|
||||
};
|
||||
let result = check_github_app(&status);
|
||||
assert_eq!(result.status, CheckStatus::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_github_partial() {
|
||||
let status = GithubAppStatus {
|
||||
app_id: true,
|
||||
client_id: true,
|
||||
client_secret: false,
|
||||
webhook_secret: false,
|
||||
private_key: false,
|
||||
};
|
||||
let result = check_github_app(&status);
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
let rem = result.remediation.unwrap();
|
||||
assert!(rem.contains("GITHUB_APP_CLIENT_SECRET"));
|
||||
assert!(rem.contains("GITHUB_APP_WEBHOOK_SECRET"));
|
||||
assert!(rem.contains("GITHUB_APP_PRIVATE_KEY"));
|
||||
}
|
||||
|
||||
// -- check_api --
|
||||
|
||||
#[test]
|
||||
fn check_api_shows_base_url() {
|
||||
let status = ApiStatus {
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
authentication_strategy: "jwt".to_string(),
|
||||
};
|
||||
let result = check_api(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert_eq!(result.summary, "http://localhost:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_api_details_show_auth_strategy() {
|
||||
let status = ApiStatus {
|
||||
base_url: "https://api.example.com".to_string(),
|
||||
authentication_strategy: "jwt".to_string(),
|
||||
};
|
||||
let result = check_api(&status);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("jwt")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("https://api.example.com")));
|
||||
}
|
||||
|
||||
// -- check_web --
|
||||
|
||||
#[test]
|
||||
fn check_web_shows_url() {
|
||||
let status = WebStatus {
|
||||
url: "http://localhost:5173".to_string(),
|
||||
auth_provider: "github".to_string(),
|
||||
allowed_usernames_count: 0,
|
||||
};
|
||||
let result = check_web(&status);
|
||||
assert_eq!(result.status, CheckStatus::Pass);
|
||||
assert_eq!(result.summary, "http://localhost:5173");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_web_details_show_auth() {
|
||||
let status = WebStatus {
|
||||
url: "https://arc.example.com".to_string(),
|
||||
auth_provider: "github".to_string(),
|
||||
allowed_usernames_count: 3,
|
||||
};
|
||||
let result = check_web(&status);
|
||||
assert!(result.details.iter().any(|d| d.text.contains("github")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("https://arc.example.com")));
|
||||
assert!(result
|
||||
.details
|
||||
.iter()
|
||||
.any(|d| d.text.contains("Allowed usernames: 3")));
|
||||
}
|
||||
|
||||
// -- render: multiple issues --
|
||||
|
||||
#[test]
|
||||
fn render_multiple_issues_pluralizes() {
|
||||
let report = DoctorReport {
|
||||
checks: vec![warning_check("A"), error_check("B")],
|
||||
};
|
||||
let out = report.render(&Styles::new(false), false);
|
||||
assert!(out.contains("2 categories"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod doctor;
|
||||
mod logging;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
@ -42,6 +43,12 @@ enum Command {
|
|||
},
|
||||
/// Start the HTTP API server
|
||||
Serve(arc_api::serve::ServeArgs),
|
||||
/// Check environment and integration health
|
||||
Doctor {
|
||||
/// Show detailed information for each check
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -80,6 +87,7 @@ async fn main() -> Result<()> {
|
|||
Command::Validate(_) => "validate",
|
||||
Command::Models { .. } => "models",
|
||||
Command::Serve(_) => "serve",
|
||||
Command::Doctor { .. } => "doctor",
|
||||
};
|
||||
debug!(command = %command_name, "CLI command started");
|
||||
|
||||
|
|
@ -114,6 +122,10 @@ async fn main() -> Result<()> {
|
|||
Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr()));
|
||||
arc_api::serve::serve_command(args, styles).await?;
|
||||
}
|
||||
Command::Doctor { verbose } => {
|
||||
let exit_code = doctor::run_doctor(verbose).await;
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -498,6 +498,63 @@ fn dry_run_legacy_tool() {
|
|||
.success();
|
||||
}
|
||||
|
||||
// == Doctor ===================================================================
|
||||
|
||||
#[test]
|
||||
fn doctor_runs_and_prints_header() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor"])
|
||||
.env_clear()
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("Arc Doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_verbose_runs_and_prints_header() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor", "-v"])
|
||||
.env_clear()
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("Arc Doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_no_color_when_no_color_set() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor"])
|
||||
.env_clear()
|
||||
.env("NO_COLOR", "1")
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("\x1b[").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_checks_llm_providers() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor"])
|
||||
.env_clear()
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("LLM providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_checks_arc_api() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor"])
|
||||
.env_clear()
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("Arc API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_checks_arc_web() {
|
||||
arc()
|
||||
.args(["--no-dotenv", "doctor"])
|
||||
.env_clear()
|
||||
.assert()
|
||||
.stdout(predicate::str::contains("Arc Web"));
|
||||
}
|
||||
|
||||
// == JSONL logging ============================================================
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,29 @@ impl Provider {
|
|||
Provider::Inception,
|
||||
];
|
||||
|
||||
/// Environment variable names that can provide the API key for this provider.
|
||||
/// Gemini accepts either `GEMINI_API_KEY` or `GOOGLE_API_KEY`.
|
||||
#[must_use]
|
||||
pub fn api_key_env_vars(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Anthropic => &["ANTHROPIC_API_KEY"],
|
||||
Self::OpenAi => &["OPENAI_API_KEY"],
|
||||
Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
Self::Kimi => &["KIMI_API_KEY"],
|
||||
Self::Zai => &["ZAI_API_KEY"],
|
||||
Self::Minimax => &["MINIMAX_API_KEY"],
|
||||
Self::Inception => &["INCEPTION_API_KEY"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if at least one of the provider's API key env vars is set.
|
||||
#[must_use]
|
||||
pub fn has_api_key(self) -> bool {
|
||||
self.api_key_env_vars()
|
||||
.iter()
|
||||
.any(|var| std::env::var(var).is_ok())
|
||||
}
|
||||
|
||||
/// Stable lowercase string representation used in `Request.provider`,
|
||||
/// adapter names, and other serialization boundaries.
|
||||
#[must_use]
|
||||
|
|
@ -212,4 +235,54 @@ mod tests {
|
|||
fn inception_as_str() {
|
||||
assert_eq!(Provider::Inception.as_str(), "inception");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_anthropic() {
|
||||
assert_eq!(
|
||||
Provider::Anthropic.api_key_env_vars(),
|
||||
&["ANTHROPIC_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_openai() {
|
||||
assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_gemini_has_two() {
|
||||
let vars = Provider::Gemini.api_key_env_vars();
|
||||
assert_eq!(vars.len(), 2);
|
||||
assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_kimi() {
|
||||
assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_zai() {
|
||||
assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_minimax() {
|
||||
assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_env_vars_inception() {
|
||||
assert_eq!(
|
||||
Provider::Inception.api_key_env_vars(),
|
||||
&["INCEPTION_API_KEY"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_provider_has_at_least_one_env_var() {
|
||||
assert!(Provider::ALL
|
||||
.iter()
|
||||
.all(|p| !p.api_key_env_vars().is_empty()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue