mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060ce8de25 | ||
|
|
485d1568c4 | ||
|
|
acb590214a |
@@ -54,7 +54,7 @@ jobs:
|
|||||||
- group: features
|
- group: features
|
||||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
||||||
- group: extensions
|
- group: extensions
|
||||||
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
||||||
- group: routines
|
- group: routines
|
||||||
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
Generated
+12
-12
@@ -157,7 +157,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4134,7 +4134,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5472,7 +5472,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6154,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6354,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6379,7 +6379,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7179,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8029,7 +8029,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||||
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
||||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
| `models` | ✅ | 🚧 | P1 | `models list [<provider>]` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set <model>`, `models set-provider <provider> [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
|
||||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(extension_manager) = self.deps.extension_manager.as_ref() {
|
||||||
|
match extension_manager.llm_extension_state_summary().await {
|
||||||
|
Ok(Some(summary)) => {
|
||||||
|
reasoning = reasoning.with_extension_state_summary(summary);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Could not load extension state summary: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(prompt) = system_prompt {
|
if let Some(prompt) = system_prompt {
|
||||||
reasoning = reasoning.with_system_prompt(prompt);
|
reasoning = reasoning.with_system_prompt(prompt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod import;
|
|||||||
mod logs;
|
mod logs;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
mod models;
|
||||||
pub mod oauth_defaults;
|
pub mod oauth_defaults;
|
||||||
mod pairing;
|
mod pairing;
|
||||||
mod registry;
|
mod registry;
|
||||||
@@ -45,6 +46,7 @@ pub use logs::{LogsCommand, run_logs_command};
|
|||||||
pub use mcp::{McpCommand, run_mcp_command};
|
pub use mcp::{McpCommand, run_mcp_command};
|
||||||
pub use memory::MemoryCommand;
|
pub use memory::MemoryCommand;
|
||||||
pub use memory::run_memory_command_with_db;
|
pub use memory::run_memory_command_with_db;
|
||||||
|
pub use models::{ModelsCommand, run_models_command};
|
||||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use registry::{RegistryCommand, run_registry_command};
|
pub use registry::{RegistryCommand, run_registry_command};
|
||||||
pub use routines::{RoutinesCommand, run_routines_command};
|
pub use routines::{RoutinesCommand, run_routines_command};
|
||||||
@@ -217,6 +219,14 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Hooks(HooksCommand),
|
Hooks(HooksCommand),
|
||||||
|
|
||||||
|
/// Manage LLM providers and models
|
||||||
|
#[command(
|
||||||
|
subcommand,
|
||||||
|
about = "Manage LLM providers and models",
|
||||||
|
long_about = "List providers, view current configuration, and set active provider/model.\nExamples:\n ironclaw models list\n ironclaw models list openai --verbose\n ironclaw models status\n ironclaw models set gpt-4o\n ironclaw models set-provider anthropic --model claude-sonnet-4-6-20250514"
|
||||||
|
)]
|
||||||
|
Models(ModelsCommand),
|
||||||
|
|
||||||
/// Probe external dependencies and validate configuration
|
/// Probe external dependencies and validate configuration
|
||||||
#[command(
|
#[command(
|
||||||
about = "Run diagnostics",
|
about = "Run diagnostics",
|
||||||
|
|||||||
@@ -0,0 +1,864 @@
|
|||||||
|
//! Models management CLI commands.
|
||||||
|
//!
|
||||||
|
//! Provides subcommands for listing providers, viewing current model
|
||||||
|
//! configuration, and setting the active provider/model. Settings are
|
||||||
|
//! persisted to both `config.toml` and `~/.ironclaw/.env` so changes
|
||||||
|
//! take effect immediately (no DB connection required).
|
||||||
|
|
||||||
|
use clap::Subcommand;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::llm::registry::ProviderRegistry;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum ModelsCommand {
|
||||||
|
/// List providers (or available models for a specific provider)
|
||||||
|
List {
|
||||||
|
/// Show only a specific provider (by ID or alias)
|
||||||
|
provider: Option<String>,
|
||||||
|
|
||||||
|
/// Show detailed information (env vars, base URL, protocol)
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show current model configuration
|
||||||
|
Status {
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set the default model
|
||||||
|
Set {
|
||||||
|
/// Model name (e.g., "gpt-5-mini", "claude-sonnet-4-6-20250514")
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set the LLM provider
|
||||||
|
SetProvider {
|
||||||
|
/// Provider ID or alias (e.g., "openai", "anthropic", "ollama")
|
||||||
|
provider: String,
|
||||||
|
|
||||||
|
/// Also set the model (defaults to provider's default model)
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the models CLI subcommand.
|
||||||
|
pub async fn run_models_command(
|
||||||
|
cmd: ModelsCommand,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match cmd {
|
||||||
|
ModelsCommand::List {
|
||||||
|
provider,
|
||||||
|
verbose,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
if let Some(ref id) = provider {
|
||||||
|
cmd_show_provider(id, verbose, json, config_path).await
|
||||||
|
} else {
|
||||||
|
cmd_list_providers(verbose, json, config_path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModelsCommand::Status { json } => cmd_status(json, config_path),
|
||||||
|
ModelsCommand::Set { model } => cmd_set_model(&model, config_path),
|
||||||
|
ModelsCommand::SetProvider { provider, model } => {
|
||||||
|
cmd_set_provider(&provider, model.as_deref(), config_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve the currently active backend and model from env + settings.
|
||||||
|
fn resolve_active(config_path: Option<&Path>) -> (String, String) {
|
||||||
|
let settings = load_settings(config_path);
|
||||||
|
resolve_active_from_settings(&settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve active backend + model from a pre-loaded Settings.
|
||||||
|
fn resolve_active_from_settings(settings: &Settings) -> (String, String) {
|
||||||
|
let backend = std::env::var("LLM_BACKEND")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.llm_backend.clone())
|
||||||
|
.unwrap_or_else(|| "nearai".to_string());
|
||||||
|
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
let canonical_backend = registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.id.clone())
|
||||||
|
.unwrap_or_else(|| backend.clone());
|
||||||
|
|
||||||
|
let model = if canonical_backend == "nearai" {
|
||||||
|
std::env::var("NEARAI_MODEL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| "qwen2.5-72b-instruct:free".to_string())
|
||||||
|
} else if let Some(def) = registry.find(&canonical_backend) {
|
||||||
|
std::env::var(&def.model_env)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| def.default_model.clone())
|
||||||
|
} else {
|
||||||
|
settings
|
||||||
|
.selected_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
(canonical_backend, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings(config_path: Option<&Path>) -> Settings {
|
||||||
|
if let Some(path) = config_path {
|
||||||
|
Settings::load_toml(path).ok().flatten().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
let toml_path = config_toml_path();
|
||||||
|
if toml_path.exists() {
|
||||||
|
Settings::load_toml(&toml_path)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Settings::load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_settings(settings: &Settings, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let path = config_path
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(config_toml_path);
|
||||||
|
|
||||||
|
settings
|
||||||
|
.save_toml(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_toml_path() -> std::path::PathBuf {
|
||||||
|
crate::bootstrap::ironclaw_base_dir().join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to fetch the live model list from a provider.
|
||||||
|
///
|
||||||
|
/// Best-effort: returns `None` if config loading, provider creation, or the
|
||||||
|
/// `list_models()` call fails (missing API key, network error, etc.).
|
||||||
|
async fn try_fetch_models(provider_id: &str, config_path: Option<&Path>) -> Option<Vec<String>> {
|
||||||
|
let config = crate::config::Config::from_env_with_toml(config_path)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Override backend to the requested provider so create_llm_provider
|
||||||
|
// constructs the right one.
|
||||||
|
let mut llm_config = config.llm.clone();
|
||||||
|
llm_config.backend = provider_id.to_string();
|
||||||
|
|
||||||
|
// For registry providers, resolve the RegistryProviderConfig if not
|
||||||
|
// already set for this backend.
|
||||||
|
if provider_id != "nearai" && provider_id != "bedrock" {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
if let Some(def) = registry.find(provider_id)
|
||||||
|
&& llm_config
|
||||||
|
.provider
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|p| p.provider_id != def.id)
|
||||||
|
{
|
||||||
|
// Build a minimal RegistryProviderConfig from env + registry
|
||||||
|
let api_key = def
|
||||||
|
.api_key_env
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|env| std::env::var(env).ok());
|
||||||
|
if def.api_key_required && api_key.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base_url = def.default_base_url.clone().unwrap_or_default();
|
||||||
|
llm_config.provider = Some(crate::llm::RegistryProviderConfig {
|
||||||
|
protocol: def.protocol,
|
||||||
|
provider_id: def.id.clone(),
|
||||||
|
model: def.default_model.clone(),
|
||||||
|
api_key: api_key.map(secrecy::SecretString::from),
|
||||||
|
base_url,
|
||||||
|
extra_headers: Vec::new(),
|
||||||
|
oauth_token: None,
|
||||||
|
is_codex_chatgpt: false,
|
||||||
|
refresh_token: None,
|
||||||
|
auth_path: None,
|
||||||
|
cache_retention: Default::default(),
|
||||||
|
unsupported_params: def.unsupported_params.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
|
||||||
|
let provider = crate::llm::create_llm_provider(&llm_config, session)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
provider.list_models().await.ok().filter(|m| !m.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print available models section (text output).
|
||||||
|
fn print_model_list(models: &Option<Vec<String>>, active_model: Option<&String>) {
|
||||||
|
match models {
|
||||||
|
Some(models) => {
|
||||||
|
println!("\n Available models ({}):", models.len());
|
||||||
|
for m in models {
|
||||||
|
let marker = active_model
|
||||||
|
.filter(|a| a.as_str() == m)
|
||||||
|
.map(|_| " (active)")
|
||||||
|
.unwrap_or("");
|
||||||
|
println!(" {}{}", m, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
println!(
|
||||||
|
"\n Could not fetch model list (missing credentials or provider unavailable)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Also update `~/.ironclaw/.env` so changes take effect immediately.
|
||||||
|
///
|
||||||
|
/// Skipped when `config_path` is `Some` (custom `--config`), because the user
|
||||||
|
/// is explicitly targeting a different config file and we must not pollute the
|
||||||
|
/// default profile's `.env`.
|
||||||
|
fn sync_to_dotenv(config_path: Option<&Path>, vars: &[(&str, &str)]) {
|
||||||
|
if config_path.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::bootstrap::upsert_bootstrap_vars(vars) {
|
||||||
|
eprintln!("Warning: failed to update .env: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_status(json: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let settings = load_settings(config_path);
|
||||||
|
let (backend, model) = resolve_active_from_settings(&settings);
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
let fallback = std::env::var("NEARAI_FALLBACK_MODEL").ok();
|
||||||
|
let cheap = std::env::var("NEARAI_CHEAP_MODEL").ok();
|
||||||
|
|
||||||
|
let description = if backend == "nearai" {
|
||||||
|
"NEAR AI inference (default)".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.description.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let v = serde_json::json!({
|
||||||
|
"provider": backend,
|
||||||
|
"model": model,
|
||||||
|
"description": description,
|
||||||
|
"fallback_model": fallback,
|
||||||
|
"cheap_model": cheap,
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Provider: {} ({})", backend, description);
|
||||||
|
println!("Model: {}", model);
|
||||||
|
if let Some(ref fb) = fallback {
|
||||||
|
println!("Fallback: {}", fb);
|
||||||
|
}
|
||||||
|
if let Some(ref ch) = cheap {
|
||||||
|
println!("Cheap: {}", ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── set ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_set_model(model: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||||
|
let trimmed = model.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
anyhow::bail!("Model name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut settings = load_settings(config_path);
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
// Warn if model name doesn't match any known provider's default model
|
||||||
|
let known_model = registry.all().iter().any(|d| d.default_model == trimmed)
|
||||||
|
|| trimmed.contains("qwen") // nearai models
|
||||||
|
|| trimmed.contains("llama")
|
||||||
|
|| trimmed.contains("gpt")
|
||||||
|
|| trimmed.contains("claude")
|
||||||
|
|| trimmed.contains("gemini")
|
||||||
|
|| trimmed.contains("mistral");
|
||||||
|
if !known_model {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: '{}' is not a recognized model name. Proceeding anyway.",
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.selected_model = Some(trimmed.to_string());
|
||||||
|
save_settings(&settings, config_path)?;
|
||||||
|
|
||||||
|
let backend = std::env::var("LLM_BACKEND")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| settings.llm_backend.clone())
|
||||||
|
.unwrap_or_else(|| "nearai".to_string());
|
||||||
|
|
||||||
|
// Also write to .env so the change takes effect immediately
|
||||||
|
let model_env = if backend == "nearai" {
|
||||||
|
"NEARAI_MODEL".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&backend)
|
||||||
|
.map(|d| d.model_env.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
if !model_env.is_empty() {
|
||||||
|
sync_to_dotenv(config_path, &[(&model_env, trimmed)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Model set to '{}' (provider: {})", trimmed, backend);
|
||||||
|
println!(
|
||||||
|
"Saved to {}",
|
||||||
|
config_path
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── set-provider ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_set_provider(
|
||||||
|
provider: &str,
|
||||||
|
model: Option<&str>,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
|
||||||
|
// Validate and normalize provider
|
||||||
|
let canonical_id = if provider == "nearai" || provider == "near_ai" || provider == "near" {
|
||||||
|
"nearai".to_string()
|
||||||
|
} else {
|
||||||
|
let def = registry.find(provider).ok_or_else(|| {
|
||||||
|
let known: Vec<&str> = std::iter::once("nearai")
|
||||||
|
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||||
|
.collect();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Unknown provider '{}'. Known providers: {}",
|
||||||
|
provider,
|
||||||
|
known.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
def.id.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve model: explicit > provider default
|
||||||
|
let resolved_model = if let Some(m) = model {
|
||||||
|
m.to_string()
|
||||||
|
} else if canonical_id == "nearai" {
|
||||||
|
"qwen2.5-72b-instruct:free".to_string()
|
||||||
|
} else if let Some(def) = registry.find(&canonical_id) {
|
||||||
|
def.default_model.clone()
|
||||||
|
} else {
|
||||||
|
"default".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut settings = load_settings(config_path);
|
||||||
|
settings.llm_backend = Some(canonical_id.clone());
|
||||||
|
settings.selected_model = Some(resolved_model.clone());
|
||||||
|
save_settings(&settings, config_path)?;
|
||||||
|
|
||||||
|
// Also write to .env so the change takes effect immediately
|
||||||
|
let model_env = if canonical_id == "nearai" {
|
||||||
|
"NEARAI_MODEL".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(&canonical_id)
|
||||||
|
.map(|d| d.model_env.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mut vars: Vec<(&str, &str)> = vec![("LLM_BACKEND", &canonical_id)];
|
||||||
|
if !model_env.is_empty() {
|
||||||
|
vars.push((&model_env, &resolved_model));
|
||||||
|
}
|
||||||
|
sync_to_dotenv(config_path, &vars);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Provider set to '{}', model set to '{}'",
|
||||||
|
canonical_id, resolved_model
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Saved to {}",
|
||||||
|
config_path
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// List all providers with their default models.
|
||||||
|
async fn cmd_list_providers(
|
||||||
|
verbose: bool,
|
||||||
|
json: bool,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let (active_backend, active_model) = resolve_active(config_path);
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
// NEAR AI (not in registry)
|
||||||
|
let nearai_active = active_backend == "nearai";
|
||||||
|
entries.push(serde_json::json!({
|
||||||
|
"id": "nearai",
|
||||||
|
"description": "NEAR AI inference (default)",
|
||||||
|
"default_model": "qwen2.5-72b-instruct:free",
|
||||||
|
"active": nearai_active,
|
||||||
|
"active_model": if nearai_active { Some(&active_model) } else { None },
|
||||||
|
}));
|
||||||
|
|
||||||
|
for def in registry.all() {
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": def.id,
|
||||||
|
"description": def.description,
|
||||||
|
"default_model": def.default_model,
|
||||||
|
"protocol": format!("{:?}", def.protocol),
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
v["aliases"] = serde_json::json!(def.aliases);
|
||||||
|
v["model_env"] = serde_json::json!(def.model_env);
|
||||||
|
v["api_key_env"] = serde_json::json!(def.api_key_env);
|
||||||
|
v["api_key_required"] = serde_json::json!(def.api_key_required);
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
v["base_url"] = serde_json::json!(url);
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = registry.all();
|
||||||
|
|
||||||
|
println!("Active: {} (model: {})\n", active_backend, active_model);
|
||||||
|
println!(
|
||||||
|
"{} provider(s) available:\n",
|
||||||
|
providers.len() + 1 // +1 for NEAR AI
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEAR AI (not in registry)
|
||||||
|
let nearai_marker = if active_backend == "nearai" { " *" } else { "" };
|
||||||
|
if verbose {
|
||||||
|
println!(" nearai{}", nearai_marker);
|
||||||
|
println!(" Description: NEAR AI inference (default)");
|
||||||
|
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||||
|
println!(" Model env: NEARAI_MODEL");
|
||||||
|
if active_backend == "nearai" {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {:<22} {:<40} NEAR AI inference (default)",
|
||||||
|
format!("nearai{nearai_marker}"),
|
||||||
|
"qwen2.5-72b-instruct:free"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for def in providers {
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
let marker = if is_active { " *" } else { "" };
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
println!(" {}{}", def.id, marker);
|
||||||
|
println!(" Description: {}", def.description);
|
||||||
|
println!(" Default model: {}", def.default_model);
|
||||||
|
println!(" Protocol: {:?}", def.protocol);
|
||||||
|
println!(" Model env: {}", def.model_env);
|
||||||
|
if let Some(ref env) = def.api_key_env {
|
||||||
|
println!(
|
||||||
|
" API key env: {} ({})",
|
||||||
|
env,
|
||||||
|
if def.api_key_required {
|
||||||
|
"required"
|
||||||
|
} else {
|
||||||
|
"optional"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
println!(" Base URL: {}", url);
|
||||||
|
}
|
||||||
|
if !def.aliases.is_empty() {
|
||||||
|
println!(" Aliases: {}", def.aliases.join(", "));
|
||||||
|
}
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
} else {
|
||||||
|
let model_display = if is_active {
|
||||||
|
active_model.clone()
|
||||||
|
} else {
|
||||||
|
def.default_model.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:<22} {:<40} {}",
|
||||||
|
format!("{}{marker}", def.id),
|
||||||
|
model_display,
|
||||||
|
def.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verbose {
|
||||||
|
println!();
|
||||||
|
println!("* = active provider. Use --verbose for details.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show details for a specific provider.
|
||||||
|
async fn cmd_show_provider(
|
||||||
|
id: &str,
|
||||||
|
verbose: bool,
|
||||||
|
json: bool,
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let (active_backend, active_model) = resolve_active(config_path);
|
||||||
|
|
||||||
|
// Resolve canonical ID for model fetching
|
||||||
|
let canonical_id = if id == "nearai" || id == "near_ai" || id == "near" {
|
||||||
|
"nearai".to_string()
|
||||||
|
} else {
|
||||||
|
registry
|
||||||
|
.find(id)
|
||||||
|
.map(|d| d.id.clone())
|
||||||
|
.unwrap_or_else(|| id.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to fetch live model list from the provider
|
||||||
|
let live_models = try_fetch_models(&canonical_id, config_path).await;
|
||||||
|
|
||||||
|
// Check NEAR AI first (not in registry)
|
||||||
|
if id == "nearai" || id == "near_ai" || id == "near" {
|
||||||
|
let is_active = active_backend == "nearai";
|
||||||
|
if json {
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": "nearai",
|
||||||
|
"description": "NEAR AI inference (default)",
|
||||||
|
"default_model": "qwen2.5-72b-instruct:free",
|
||||||
|
"model_env": "NEARAI_MODEL",
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if let Some(ref models) = live_models {
|
||||||
|
v["available_models"] = serde_json::json!(models);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Provider: nearai");
|
||||||
|
println!(" Description: NEAR AI inference (default)");
|
||||||
|
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||||
|
println!(" Model env: NEARAI_MODEL");
|
||||||
|
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let def = registry.find(id).ok_or_else(|| {
|
||||||
|
let known: Vec<&str> = std::iter::once("nearai")
|
||||||
|
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||||
|
.collect();
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Unknown provider '{}'. Known providers: {}",
|
||||||
|
id,
|
||||||
|
known.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let is_active = active_backend == def.id;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let mut v = serde_json::json!({
|
||||||
|
"id": def.id,
|
||||||
|
"description": def.description,
|
||||||
|
"protocol": format!("{:?}", def.protocol),
|
||||||
|
"default_model": def.default_model,
|
||||||
|
"model_env": def.model_env,
|
||||||
|
"api_key_env": def.api_key_env,
|
||||||
|
"api_key_required": def.api_key_required,
|
||||||
|
"aliases": def.aliases,
|
||||||
|
"active": is_active,
|
||||||
|
});
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
v["base_url"] = serde_json::json!(url);
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||||
|
v["display_name"] = serde_json::json!(setup.display_name());
|
||||||
|
}
|
||||||
|
if is_active {
|
||||||
|
v["active_model"] = serde_json::json!(active_model);
|
||||||
|
}
|
||||||
|
if verbose && !def.unsupported_params.is_empty() {
|
||||||
|
v["unsupported_params"] = serde_json::json!(def.unsupported_params);
|
||||||
|
}
|
||||||
|
if let Some(ref models) = live_models {
|
||||||
|
v["available_models"] = serde_json::json!(models);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Provider: {}", def.id);
|
||||||
|
println!(" Description: {}", def.description);
|
||||||
|
println!(" Protocol: {:?}", def.protocol);
|
||||||
|
println!(" Default model: {}", def.default_model);
|
||||||
|
println!(" Model env: {}", def.model_env);
|
||||||
|
if let Some(ref env) = def.api_key_env {
|
||||||
|
println!(
|
||||||
|
" API key env: {} ({})",
|
||||||
|
env,
|
||||||
|
if def.api_key_required {
|
||||||
|
"required"
|
||||||
|
} else {
|
||||||
|
"optional"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref url) = def.default_base_url {
|
||||||
|
println!(" Base URL: {}", url);
|
||||||
|
}
|
||||||
|
if !def.aliases.is_empty() {
|
||||||
|
println!(" Aliases: {}", def.aliases.join(", "));
|
||||||
|
}
|
||||||
|
if let Some(ref setup) = def.setup {
|
||||||
|
println!(
|
||||||
|
" List models: {}",
|
||||||
|
if setup.can_list_models() {
|
||||||
|
"supported"
|
||||||
|
} else {
|
||||||
|
"not supported"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" Display name: {}", setup.display_name());
|
||||||
|
}
|
||||||
|
if !def.unsupported_params.is_empty() {
|
||||||
|
println!(" Unsupported: {}", def.unsupported_params.join(", "));
|
||||||
|
}
|
||||||
|
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||||
|
if is_active {
|
||||||
|
println!(" Active model: {}", active_model);
|
||||||
|
}
|
||||||
|
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_active_defaults_to_nearai() {
|
||||||
|
let settings = Settings::default();
|
||||||
|
assert!(settings.llm_backend.is_none());
|
||||||
|
assert!(settings.selected_model.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_loads_all_providers() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let all = registry.all();
|
||||||
|
assert!(
|
||||||
|
all.len() >= 10,
|
||||||
|
"should have at least 10 built-in providers, got {}",
|
||||||
|
all.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_find_by_alias() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
let def = registry
|
||||||
|
.find("claude")
|
||||||
|
.expect("claude alias should resolve");
|
||||||
|
assert_eq!(def.id, "anthropic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_providers_have_description() {
|
||||||
|
let registry = ProviderRegistry::load();
|
||||||
|
for def in registry.all() {
|
||||||
|
assert!(
|
||||||
|
!def.description.is_empty(),
|
||||||
|
"provider {} should have a description",
|
||||||
|
def.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_model_persists_to_toml() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_model("gpt-5-mini", Some(&toml_path)).expect("set model");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.selected_model.as_deref(), Some("gpt-5-mini"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_validates_unknown() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let result = cmd_set_provider("nonexistent_provider", None, Some(&toml_path));
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("Unknown provider"),
|
||||||
|
"should mention unknown provider: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_persists_to_toml() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||||
|
assert_eq!(
|
||||||
|
settings.selected_model.as_deref(),
|
||||||
|
Some("llama-3.3-70b-versatile")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_with_custom_model() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("anthropic", Some("claude-opus-4-6"), Some(&toml_path))
|
||||||
|
.expect("set provider with model");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("anthropic"));
|
||||||
|
assert_eq!(settings.selected_model.as_deref(), Some("claude-opus-4-6"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_config_does_not_pollute_default_dotenv() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
// With a custom config path, sync_to_dotenv should be a no-op
|
||||||
|
// (it returns early when config_path is Some).
|
||||||
|
// We verify by checking that cmd_set_provider succeeds without
|
||||||
|
// trying to write to the default ~/.ironclaw/.env.
|
||||||
|
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider with custom config");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||||
|
// The key assertion is that no error was thrown trying to write
|
||||||
|
// to the default .env — sync_to_dotenv skipped it.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_model_rejects_empty_name() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
let result = cmd_set_model("", Some(&toml_path));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(
|
||||||
|
result.unwrap_err().to_string().contains("cannot be empty"),
|
||||||
|
"should reject empty model name"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result2 = cmd_set_model(" ", Some(&toml_path));
|
||||||
|
assert!(result2.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_provider_normalizes_alias() {
|
||||||
|
let dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let toml_path = dir.path().join("config.toml");
|
||||||
|
|
||||||
|
cmd_set_provider("claude", None, Some(&toml_path)).expect("set via alias");
|
||||||
|
|
||||||
|
let settings = Settings::load_toml(&toml_path)
|
||||||
|
.expect("read toml")
|
||||||
|
.expect("should have settings");
|
||||||
|
assert_eq!(
|
||||||
|
settings.llm_backend.as_deref(),
|
||||||
|
Some("anthropic"),
|
||||||
|
"alias should be normalized to canonical ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Commands:
|
|||||||
service Manage OS service
|
service Manage OS service
|
||||||
skills Manage skills
|
skills Manage skills
|
||||||
hooks Manage lifecycle hooks
|
hooks Manage lifecycle hooks
|
||||||
|
models Manage LLM providers and models
|
||||||
doctor Run diagnostics
|
doctor Run diagnostics
|
||||||
logs View and manage gateway logs
|
logs View and manage gateway logs
|
||||||
status Show system status
|
status Show system status
|
||||||
|
|||||||
@@ -1498,6 +1498,52 @@ impl ExtensionManager {
|
|||||||
Ok(extensions)
|
Ok(extensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a compact, deterministic extension snapshot for LLM prompt context.
|
||||||
|
pub async fn llm_extension_state_summary(&self) -> Result<Option<String>, ExtensionError> {
|
||||||
|
let mut extensions = self.list(None, false).await?;
|
||||||
|
extensions.sort_by(|a, b| {
|
||||||
|
llm_extension_sort_key(a.kind)
|
||||||
|
.cmp(&llm_extension_sort_key(b.kind))
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut channels = Vec::new();
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
let mut servers = Vec::new();
|
||||||
|
|
||||||
|
for extension in extensions {
|
||||||
|
let owner_bound = matches!(extension.kind, ExtensionKind::WasmChannel)
|
||||||
|
&& self.has_wasm_channel_owner_binding(&extension.name).await;
|
||||||
|
if !(extension.active || extension.authenticated || owner_bound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = llm_extension_summary_item(&extension, owner_bound);
|
||||||
|
match extension.kind {
|
||||||
|
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => channels.push(item),
|
||||||
|
ExtensionKind::WasmTool => tools.push(item),
|
||||||
|
ExtensionKind::McpServer => servers.push(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if !channels.is_empty() {
|
||||||
|
lines.push(format!("- Channels: {}", channels.join("; ")));
|
||||||
|
}
|
||||||
|
if !tools.is_empty() {
|
||||||
|
lines.push(format!("- Tools: {}", tools.join("; ")));
|
||||||
|
}
|
||||||
|
if !servers.is_empty() {
|
||||||
|
lines.push(format!("- MCP servers: {}", servers.join("; ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(lines.join("\n")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove an installed extension.
|
/// Remove an installed extension.
|
||||||
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
||||||
Self::validate_extension_name(name)?;
|
Self::validate_extension_name(name)?;
|
||||||
@@ -5613,6 +5659,40 @@ fn combine_install_errors(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn llm_extension_sort_key(kind: ExtensionKind) -> u8 {
|
||||||
|
match kind {
|
||||||
|
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => 0,
|
||||||
|
ExtensionKind::WasmTool => 1,
|
||||||
|
ExtensionKind::McpServer => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn llm_extension_summary_item(extension: &InstalledExtension, owner_bound: bool) -> String {
|
||||||
|
let mut states = Vec::new();
|
||||||
|
if extension.authenticated {
|
||||||
|
states.push("authenticated".to_string());
|
||||||
|
}
|
||||||
|
if extension.active {
|
||||||
|
states.push("active".to_string());
|
||||||
|
} else if extension.authenticated {
|
||||||
|
states.push("inactive".to_string());
|
||||||
|
}
|
||||||
|
if owner_bound {
|
||||||
|
states.push("owner-bound".to_string());
|
||||||
|
}
|
||||||
|
if !extension.tools.is_empty() {
|
||||||
|
let mut tool_names = extension.tools.clone();
|
||||||
|
tool_names.sort();
|
||||||
|
states.push(format!("tools: {}", tool_names.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if states.is_empty() {
|
||||||
|
extension.name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} ({})", extension.name, states.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
@@ -6571,6 +6651,142 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_llm_extension_state_summary_reports_active_owner_bound_telegram()
|
||||||
|
-> Result<(), String> {
|
||||||
|
let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?;
|
||||||
|
let channels_dir = dir.path().join("channels");
|
||||||
|
std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?;
|
||||||
|
std::fs::write(channels_dir.join("telegram.wasm"), b"mock")
|
||||||
|
.map_err(|err| format!("write wasm: {err}"))?;
|
||||||
|
std::fs::write(
|
||||||
|
channels_dir.join("telegram.capabilities.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"type": "channel",
|
||||||
|
"name": "telegram",
|
||||||
|
"setup": {
|
||||||
|
"required_secrets": [
|
||||||
|
{
|
||||||
|
"name": "telegram_bot_token",
|
||||||
|
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"channel": {
|
||||||
|
"allowed_paths": ["/webhook/telegram"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"owner_id": null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.map_err(|err| format!("serialize capabilities: {err}"))?,
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("write capabilities: {err}"))?;
|
||||||
|
|
||||||
|
let (db, _db_tmp) = crate::testing::test_db().await;
|
||||||
|
let manager = {
|
||||||
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||||
|
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||||
|
use crate::tools::ToolRegistry;
|
||||||
|
use crate::tools::mcp::process::McpProcessManager;
|
||||||
|
use crate::tools::mcp::session::McpSessionManager;
|
||||||
|
|
||||||
|
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||||
|
let crypto = Arc::new(
|
||||||
|
SecretsCrypto::new(master_key)
|
||||||
|
.map_err(|err| format!("failed to construct test crypto: {err}"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
ExtensionManager::new(
|
||||||
|
Arc::new(McpSessionManager::new()),
|
||||||
|
Arc::new(McpProcessManager::new()),
|
||||||
|
Arc::new(InMemorySecretsStore::new(crypto)),
|
||||||
|
Arc::new(ToolRegistry::new()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
dir.path().join("tools"),
|
||||||
|
channels_dir.clone(),
|
||||||
|
None,
|
||||||
|
"test".to_string(),
|
||||||
|
Some(db),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel_manager = Arc::new(ChannelManager::new());
|
||||||
|
let runtime = Arc::new(
|
||||||
|
WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing())
|
||||||
|
.map_err(|err| format!("runtime: {err}"))?,
|
||||||
|
);
|
||||||
|
let pairing_store = Arc::new(PairingStore::with_base_dir(
|
||||||
|
dir.path().join("pairing-state"),
|
||||||
|
));
|
||||||
|
let router = Arc::new(WasmChannelRouter::new());
|
||||||
|
manager
|
||||||
|
.set_channel_runtime(
|
||||||
|
Arc::clone(&channel_manager),
|
||||||
|
Arc::clone(&runtime),
|
||||||
|
Arc::clone(&pairing_store),
|
||||||
|
Arc::clone(&router),
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
manager
|
||||||
|
.set_test_wasm_channel_loader(Arc::new({
|
||||||
|
let runtime = Arc::clone(&runtime);
|
||||||
|
let pairing_store = Arc::clone(&pairing_store);
|
||||||
|
move |name| {
|
||||||
|
Ok(make_test_loaded_channel(
|
||||||
|
Arc::clone(&runtime),
|
||||||
|
name,
|
||||||
|
Arc::clone(&pairing_store),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
manager
|
||||||
|
.set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| {
|
||||||
|
if existing_owner_id.is_some() {
|
||||||
|
return Err(ExtensionError::Other(
|
||||||
|
"owner binding should be derived during setup".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(TelegramBindingResult::Bound(TelegramBindingData {
|
||||||
|
owner_id: 424242,
|
||||||
|
bot_username: Some("test_hot_bot".to_string()),
|
||||||
|
binding_state: TelegramOwnerBindingState::VerifiedNow,
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.configure(
|
||||||
|
"telegram",
|
||||||
|
&std::collections::HashMap::from([(
|
||||||
|
"telegram_bot_token".to_string(),
|
||||||
|
"123456789:ABCdefGhI".to_string(),
|
||||||
|
)]),
|
||||||
|
&std::collections::HashMap::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("configure succeeds: {err}"))?;
|
||||||
|
|
||||||
|
let summary = manager
|
||||||
|
.llm_extension_state_summary()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("summary: {err}"))?
|
||||||
|
.ok_or_else(|| "expected extension summary".to_string())?;
|
||||||
|
|
||||||
|
require(
|
||||||
|
summary.contains("- Channels: telegram (authenticated, active, owner-bound)"),
|
||||||
|
format!("unexpected summary: {summary}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
|
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
|
||||||
-> Result<(), String> {
|
-> Result<(), String> {
|
||||||
|
|||||||
+92
-12
@@ -353,6 +353,8 @@ pub struct Reasoning {
|
|||||||
workspace_system_prompt: Option<String>,
|
workspace_system_prompt: Option<String>,
|
||||||
/// Optional skill context block to inject into system prompt.
|
/// Optional skill context block to inject into system prompt.
|
||||||
skill_context: Option<String>,
|
skill_context: Option<String>,
|
||||||
|
/// Optional snapshot of connected/active extensions for the current user.
|
||||||
|
extension_state_summary: Option<String>,
|
||||||
/// Channel name (e.g. "discord", "telegram") for formatting hints.
|
/// Channel name (e.g. "discord", "telegram") for formatting hints.
|
||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
/// Model name for runtime context.
|
/// Model name for runtime context.
|
||||||
@@ -371,6 +373,7 @@ impl Reasoning {
|
|||||||
llm,
|
llm,
|
||||||
workspace_system_prompt: None,
|
workspace_system_prompt: None,
|
||||||
skill_context: None,
|
skill_context: None,
|
||||||
|
extension_state_summary: None,
|
||||||
channel: None,
|
channel: None,
|
||||||
model_name: None,
|
model_name: None,
|
||||||
is_group_chat: false,
|
is_group_chat: false,
|
||||||
@@ -400,6 +403,14 @@ impl Reasoning {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set extension runtime context to inject into the system prompt.
|
||||||
|
pub fn with_extension_state_summary(mut self, summary: String) -> Self {
|
||||||
|
if !summary.is_empty() {
|
||||||
|
self.extension_state_summary = Some(summary);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the channel name for channel-specific formatting hints.
|
/// Set the channel name for channel-specific formatting hints.
|
||||||
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
|
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
|
||||||
let ch = channel.into();
|
let ch = channel.into();
|
||||||
@@ -932,21 +943,54 @@ Example:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
||||||
// Only include when the extension management tools are available
|
let has_search = tools.iter().any(|t| t.name == "tool_search");
|
||||||
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
|
let has_list = tools.iter().any(|t| t.name == "tool_list");
|
||||||
if !has_ext_tools {
|
let has_info = tools.iter().any(|t| t.name == "extension_info");
|
||||||
|
|
||||||
|
if self.extension_state_summary.is_none() && !has_search && !has_list && !has_info {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
"\n\n## Extensions\n\
|
let mut blocks = Vec::new();
|
||||||
You can search, install, and activate extensions to add new capabilities:\n\
|
if let Some(ref summary) = self.extension_state_summary {
|
||||||
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
blocks.push(format!(
|
||||||
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
"Current extension state for this user:\n{}",
|
||||||
- **Tools** — sandboxed functions that extend your abilities.\n\
|
summary
|
||||||
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
|
));
|
||||||
Use `tool_search` to find extensions by name. Refer to them by their kind \
|
}
|
||||||
(channel, tool, or server) — not as \"MCP server\" generically."
|
|
||||||
.to_string()
|
if has_search || has_list || has_info {
|
||||||
|
let mut guidance = String::from(
|
||||||
|
"You can search, install, and activate extensions to add new capabilities:\n\
|
||||||
|
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
||||||
|
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
||||||
|
- **Tools** — sandboxed functions that extend your abilities.\n\
|
||||||
|
- **MCP servers** — external API integrations via the Model Context Protocol.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if has_list {
|
||||||
|
guidance.push_str(
|
||||||
|
"\n\nBefore telling the user to connect, activate, or re-enable an extension, \
|
||||||
|
inspect the current state with `tool_list`.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if has_info {
|
||||||
|
guidance.push_str(
|
||||||
|
"\nUse `extension_info` when you need deeper compatibility or runtime details \
|
||||||
|
for an installed extension.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if has_search {
|
||||||
|
guidance.push_str(
|
||||||
|
"\nUse `tool_search` to find extensions by name. Refer to them by their kind \
|
||||||
|
(channel, tool, or server) — not as \"MCP server\" generically.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push(guidance);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("\n\n## Extensions\n{}", blocks.join("\n\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_channel_section(&self) -> String {
|
fn build_channel_section(&self) -> String {
|
||||||
@@ -2285,6 +2329,42 @@ That's my plan."#;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_prompt_includes_extension_runtime_summary() {
|
||||||
|
let reasoning = make_test_reasoning().with_extension_state_summary(
|
||||||
|
"- Channels: telegram (authenticated, active, owner-bound)".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("## Extensions"),
|
||||||
|
"Prompt should contain an Extensions section when runtime state is present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("telegram (authenticated, active, owner-bound)"),
|
||||||
|
"Prompt should include the injected extension runtime summary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_prompt_extension_guidance_prefers_inspection_before_reconnect() {
|
||||||
|
let reasoning = make_test_reasoning();
|
||||||
|
let prompt = reasoning.build_system_prompt_with_tools(&make_tools(&[
|
||||||
|
"tool_search",
|
||||||
|
"tool_list",
|
||||||
|
"extension_info",
|
||||||
|
]));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
prompt.contains("inspect the current state with `tool_list`"),
|
||||||
|
"Prompt should direct the model to inspect installed/active state first"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("Use `extension_info` when you need deeper compatibility"),
|
||||||
|
"Prompt should mention extension_info for deeper extension details"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
||||||
}
|
}
|
||||||
|
Some(Command::Models(models_cmd)) => {
|
||||||
|
init_cli_tracing();
|
||||||
|
return ironclaw::cli::run_models_command(models_cmd.clone(), cli.config.as_deref())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Some(Command::Doctor) => {
|
Some(Command::Doctor) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return ironclaw::cli::run_doctor_command().await;
|
return ironclaw::cli::run_doctor_command().await;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""OAuth URL parameter validation e2e tests.
|
||||||
|
|
||||||
|
Tests for bug #992: Google OAuth URL broken when initiated from Telegram.
|
||||||
|
Specifically verifies that OAuth query parameters are correctly formatted:
|
||||||
|
- "client_id" (with underscore) NOT "clientid" (without underscore)
|
||||||
|
- All standard OAuth parameters are present and correctly encoded
|
||||||
|
- URLs are consistent across channels (web, Telegram, etc.)
|
||||||
|
|
||||||
|
The test verifies:
|
||||||
|
1. OAuth URL is generated with correct parameters
|
||||||
|
2. URL works with the OAuth provider (Google)
|
||||||
|
3. Extra parameters (access_type, prompt) are preserved
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from helpers import api_post, api_get
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_oauth_params(auth_url: str) -> dict:
|
||||||
|
"""Extract and validate OAuth query parameters from auth_url.
|
||||||
|
|
||||||
|
Returns dict with parsed parameters:
|
||||||
|
{
|
||||||
|
'client_id': '...',
|
||||||
|
'redirect_uri': '...',
|
||||||
|
'response_type': 'code',
|
||||||
|
'scope': '...',
|
||||||
|
'state': '...',
|
||||||
|
'access_type': '...',
|
||||||
|
'prompt': '...',
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
parsed = urlparse(auth_url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
# Convert lists to single values for easier testing
|
||||||
|
params = {k: v[0] if len(v) > 0 else v for k, v in qs.items()}
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_extension(ironclaw_server, name):
|
||||||
|
"""Get a specific extension from the extensions list, or None."""
|
||||||
|
r = await api_get(ironclaw_server, "/api/extensions")
|
||||||
|
for ext in r.json().get("extensions", []):
|
||||||
|
if ext["name"] == name:
|
||||||
|
return ext
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def installed_gmail(ironclaw_server):
|
||||||
|
"""Installs the 'gmail' extension before a test and removes it after.
|
||||||
|
|
||||||
|
This fixture handles the setup and teardown of the Gmail extension,
|
||||||
|
ensuring a clean state for each test.
|
||||||
|
"""
|
||||||
|
# Ensure Gmail is not installed before test
|
||||||
|
ext = await _get_extension(ironclaw_server, "gmail")
|
||||||
|
if ext:
|
||||||
|
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Install Gmail
|
||||||
|
r = await api_post(
|
||||||
|
ironclaw_server,
|
||||||
|
"/api/extensions/install",
|
||||||
|
json={"name": "gmail"},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"Gmail install failed: {r.text}"
|
||||||
|
assert r.json().get("success") is True, f"Install failed: {r.json().get('message', '')}"
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Teardown: remove gmail
|
||||||
|
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
|
||||||
|
assert r.status_code == 200, f"Gmail removal failed: {r.text}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_url(ironclaw_server, installed_gmail):
|
||||||
|
"""Generate and return an OAuth auth URL.
|
||||||
|
|
||||||
|
Requires Gmail to be installed (depends on installed_gmail fixture).
|
||||||
|
"""
|
||||||
|
r = await api_post(
|
||||||
|
ironclaw_server,
|
||||||
|
"/api/extensions/gmail/setup",
|
||||||
|
json={"secrets": {}},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data.get("success") is True, f"Setup failed: {data.get('message', '')}"
|
||||||
|
|
||||||
|
url = data.get("auth_url")
|
||||||
|
assert url is not None, f"Expected auth_url in response: {data}"
|
||||||
|
assert "accounts.google.com" in url, f"auth_url should point to Google: {url}"
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def oauth_params(auth_url):
|
||||||
|
"""Extract and return OAuth parameters from auth_url.
|
||||||
|
|
||||||
|
Depends on auth_url fixture.
|
||||||
|
"""
|
||||||
|
return await _extract_oauth_params(auth_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ─ OAuth URL parameter validation tests ────────────────────────────────
|
||||||
|
|
||||||
|
async def test_oauth_url_has_client_id_not_clientid(oauth_params, auth_url):
|
||||||
|
"""Verify OAuth URL has 'client_id' (with underscore), NOT 'clientid'.
|
||||||
|
|
||||||
|
Bug #992: Ensure the parameter name is correct across all channels.
|
||||||
|
"""
|
||||||
|
params = oauth_params
|
||||||
|
|
||||||
|
# The bug: "clientid" appears instead of "client_id"
|
||||||
|
# Verify the CORRECT parameter name exists
|
||||||
|
assert "client_id" in params, (
|
||||||
|
f"OAuth URL missing 'client_id' parameter. "
|
||||||
|
f"URL: {auth_url}\nParams: {params}"
|
||||||
|
)
|
||||||
|
assert params["client_id"], "client_id should have a value"
|
||||||
|
|
||||||
|
# Verify the INCORRECT parameter name does NOT exist
|
||||||
|
assert "clientid" not in params, (
|
||||||
|
f"OAuth URL should NOT have 'clientid' (without underscore). "
|
||||||
|
f"Bug #992: URL: {auth_url}\nParams: {params}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_url_has_required_parameters(oauth_params):
|
||||||
|
"""Verify all required OAuth 2.0 parameters are present."""
|
||||||
|
params = oauth_params
|
||||||
|
|
||||||
|
# Required OAuth 2.0 parameters
|
||||||
|
required = ["client_id", "response_type", "redirect_uri", "scope", "state"]
|
||||||
|
for param in required:
|
||||||
|
assert param in params, (
|
||||||
|
f"Missing required OAuth parameter: {param}. "
|
||||||
|
f"Params: {params}"
|
||||||
|
)
|
||||||
|
assert params[param], f"Parameter '{param}' should have a non-empty value"
|
||||||
|
|
||||||
|
# Validate specific values
|
||||||
|
assert params["response_type"] == "code", "Should use authorization_code flow"
|
||||||
|
assert "oauth" in params["redirect_uri"], "Redirect URI should be an OAuth callback"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_url_has_extra_params(oauth_params):
|
||||||
|
"""Verify extra_params from capabilities.json are included."""
|
||||||
|
params = oauth_params
|
||||||
|
|
||||||
|
# Google-specific extra_params from gmail-tool.capabilities.json
|
||||||
|
assert "access_type" in params, (
|
||||||
|
"Should include 'access_type' from extra_params"
|
||||||
|
)
|
||||||
|
assert params["access_type"] == "offline", (
|
||||||
|
"access_type should be 'offline' for Gmail"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "prompt" in params, (
|
||||||
|
"Should include 'prompt' from extra_params"
|
||||||
|
)
|
||||||
|
assert params["prompt"] == "consent", (
|
||||||
|
"prompt should be 'consent' for Gmail"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_url_is_valid_google_oauth(auth_url):
|
||||||
|
"""Verify the URL is a valid Google OAuth 2.0 authorization URL."""
|
||||||
|
# Verify scheme and host
|
||||||
|
parsed = urlparse(auth_url)
|
||||||
|
assert parsed.scheme == "https", "OAuth URL must use HTTPS"
|
||||||
|
assert "accounts.google.com" in parsed.netloc, "Must be Google's OAuth endpoint"
|
||||||
|
assert parsed.path == "/o/oauth2/v2/auth", "Must use Google OAuth 2.0 endpoint"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_url_state_is_unique(ironclaw_server, installed_gmail, oauth_params, auth_url):
|
||||||
|
"""Verify CSRF state is present and unique per request."""
|
||||||
|
# Get a new OAuth URL
|
||||||
|
r = await api_post(
|
||||||
|
ironclaw_server,
|
||||||
|
"/api/extensions/gmail/setup",
|
||||||
|
json={"secrets": {}},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
new_auth_url = r.json().get("auth_url")
|
||||||
|
assert new_auth_url is not None
|
||||||
|
|
||||||
|
# Extract state from both URLs
|
||||||
|
original_params = oauth_params
|
||||||
|
new_params = await _extract_oauth_params(new_auth_url)
|
||||||
|
|
||||||
|
original_state = original_params.get("state")
|
||||||
|
new_state = new_params.get("state")
|
||||||
|
|
||||||
|
assert original_state is not None, "Should have state parameter"
|
||||||
|
assert new_state is not None, "New request should have state parameter"
|
||||||
|
assert original_state != new_state, (
|
||||||
|
"CSRF state should be unique per request (for security)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_url_escaping(auth_url):
|
||||||
|
"""Verify URL query parameters are properly escaped."""
|
||||||
|
# Verify special characters in values are URL-encoded
|
||||||
|
# For example, scopes contain spaces which should be %20
|
||||||
|
assert "%20" in auth_url or "+" in auth_url or "%2B" in auth_url or " " not in auth_url, (
|
||||||
|
"OAuth URL should properly encode special characters in parameters"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─ Telegram-specific tests (when Telegram channel is available) ──────────
|
||||||
|
|
||||||
|
class TestOAuthURLViaTelegram:
|
||||||
|
"""Test OAuth URL generation specifically via Telegram channel.
|
||||||
|
|
||||||
|
These tests would verify that the same OAuth URL works correctly when
|
||||||
|
transmitted through the Telegram WASM channel (as opposed to web gateway).
|
||||||
|
|
||||||
|
Currently marked as xfail pending Telegram channel setup in E2E tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
|
||||||
|
async def test_telegram_oauth_url_has_correct_parameters(self):
|
||||||
|
"""Verify OAuth URL sent via Telegram has correct parameter names."""
|
||||||
|
# This test would:
|
||||||
|
# 1. Send a message via Telegram that triggers OAuth
|
||||||
|
# 2. Capture the status update sent to Telegram
|
||||||
|
# 3. Extract the auth_url from the message
|
||||||
|
# 4. Verify it has "client_id" not "clientid"
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
|
||||||
|
async def test_telegram_oauth_url_can_be_regenerated(self):
|
||||||
|
"""Verify OAuth URL can be regenerated when requested via Telegram."""
|
||||||
|
# This test would verify that the bug #992 symptom
|
||||||
|
# "URL cannot be regenerated when asked" is fixed.
|
||||||
|
# If the URL is cached incorrectly, regeneration would fail.
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user