Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| NVIDIA API | ✅ | ❌ | P3 | New provider |
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| Avian | ❌ | ✅ | - | DeepSeek, Kimi, GLM models (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
Expand Down Expand Up @@ -521,6 +522,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Memory CLI commands (search, read, write, tree, status)
- ✅ Shell env scrubbing + command injection detection
- ✅ Tinfoil private inference provider
- ✅ Avian inference provider
- ✅ OpenAI-compatible / OpenRouter provider support

### P1 - High Priority
Expand Down Expand Up @@ -579,7 +581,8 @@ IronClaw intentionally differs from OpenClaw in these ways:
5. **No mobile/desktop apps**: Focus on server-side and CLI initially
6. **WASM channels**: Novel extension mechanism not in OpenClaw
7. **Tinfoil private inference**: IronClaw-only provider for private/encrypted inference
8. **GitHub WASM tool**: Native GitHub integration as WASM tool
9. **Prompt-based skills**: Different approach than OpenClaw capability bundles (trust gating, attenuation)
8. **Avian inference**: IronClaw-only provider for DeepSeek, Kimi, GLM, and MiniMax models
9. **GitHub WASM tool**: Native GitHub integration as WASM tool
10. **Prompt-based skills**: Different approach than OpenClaw capability bundles (trust gating, attenuation)

These are intentional architectural choices, not gaps to be filled.
22 changes: 22 additions & 0 deletions docs/LLM_PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ configurations.
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
| Ollama | `ollama` | No | Local inference |
| Avian | `avian` | `AVIAN_API_KEY` | DeepSeek, Kimi, GLM |
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
| Together AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
| Fireworks AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
Expand Down Expand Up @@ -68,6 +69,27 @@ Pull a model first: `ollama pull llama3.2`

---

## Avian

[Avian](https://avian.io) provides access to DeepSeek, Kimi, GLM, and MiniMax models.

```env
LLM_BACKEND=avian
AVIAN_API_KEY=...
AVIAN_MODEL=deepseek/deepseek-v3.2
```

Available models:

| Model | ID | Context |
|---|---|---|
| DeepSeek V3.2 | `deepseek/deepseek-v3.2` | 164K |
| Kimi K2.5 | `moonshotai/kimi-k2.5` | 131K |
| GLM-5 | `z-ai/glm-5` | 131K |
| MiniMax M2.5 | `minimax/minimax-m2.5` | 1M |

---

## OpenAI-Compatible Endpoints

All providers below use `LLM_BACKEND=openai_compatible`. Set `LLM_BASE_URL` to the
Expand Down
30 changes: 29 additions & 1 deletion src/config/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ pub enum LlmBackend {
OpenAiCompatible,
/// Tinfoil private inference
Tinfoil,
/// Avian AI inference
Avian,
}

impl std::str::FromStr for LlmBackend {
Expand All @@ -38,8 +40,9 @@ impl std::str::FromStr for LlmBackend {
"ollama" => Ok(Self::Ollama),
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
"tinfoil" => Ok(Self::Tinfoil),
"avian" => Ok(Self::Avian),
_ => Err(format!(
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil",
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil, avian",
s
)),
}
Expand All @@ -55,6 +58,7 @@ impl std::fmt::Display for LlmBackend {
Self::Ollama => write!(f, "ollama"),
Self::OpenAiCompatible => write!(f, "openai_compatible"),
Self::Tinfoil => write!(f, "tinfoil"),
Self::Avian => write!(f, "avian"),
}
}
}
Expand Down Expand Up @@ -102,6 +106,13 @@ pub struct TinfoilConfig {
pub model: String,
}

/// Configuration for Avian AI inference.
#[derive(Debug, Clone)]
pub struct AvianConfig {
pub api_key: SecretString,
pub model: String,
}

/// LLM provider configuration.
///
/// NEAR AI remains the default backend. Users can switch to other providers
Expand All @@ -122,6 +133,8 @@ pub struct LlmConfig {
pub openai_compatible: Option<OpenAiCompatibleConfig>,
/// Tinfoil config (populated when backend=tinfoil)
pub tinfoil: Option<TinfoilConfig>,
/// Avian config (populated when backend=avian)
pub avian: Option<AvianConfig>,
}

/// NEAR AI configuration.
Expand Down Expand Up @@ -325,6 +338,20 @@ impl LlmConfig {
None
};

let avian = if backend == LlmBackend::Avian {
let api_key = optional_env("AVIAN_API_KEY")?
.map(SecretString::from)
.ok_or_else(|| ConfigError::MissingRequired {
key: "AVIAN_API_KEY".to_string(),
hint: "Set AVIAN_API_KEY when LLM_BACKEND=avian".to_string(),
})?;
let model = optional_env("AVIAN_MODEL")?
.unwrap_or_else(|| "deepseek/deepseek-v3.2".to_string());
Some(AvianConfig { api_key, model })
} else {
None
};

Ok(Self {
backend,
nearai,
Expand All @@ -333,6 +360,7 @@ impl LlmConfig {
ollama,
openai_compatible,
tinfoil,
avian,
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
AnthropicDirectConfig, AvianConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
};
pub use self::routines::RoutineConfig;
Expand Down
29 changes: 29 additions & 0 deletions src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub fn create_llm_provider(
LlmBackend::Ollama => create_ollama_provider(config),
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
LlmBackend::Tinfoil => create_tinfoil_provider(config),
LlmBackend::Avian => create_avian_provider(config),
}
}

Expand Down Expand Up @@ -210,6 +211,33 @@ fn create_tinfoil_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, L
Ok(Arc::new(RigAdapter::new(model, &tf.model)))
}

const AVIAN_BASE_URL: &str = "https://api.avian.io/v1";

fn create_avian_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let av = config
.avian
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "avian".to_string(),
})?;

use rig::providers::openai;

let client: openai::CompletionsClient = openai::Client::builder()
.base_url(AVIAN_BASE_URL)
.api_key(av.api_key.expose_secret())
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "avian".to_string(),
reason: format!("Failed to create Avian client: {}", e),
})?
.completions_api();

let model = client.completion_model(&av.model);
tracing::info!("Using Avian AI inference (model: {})", av.model);
Ok(Arc::new(RigAdapter::new(model, &av.model)))
}

fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let compat = config
.openai_compatible
Expand Down Expand Up @@ -472,6 +500,7 @@ mod tests {
ollama: None,
openai_compatible: None,
tinfoil: None,
avian: None,
}
}

Expand Down
41 changes: 39 additions & 2 deletions src/setup/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,7 @@ impl SetupWizard {
"anthropic" => "Anthropic (Claude)",
"openai" => "OpenAI",
"ollama" => "Ollama (local)",
"avian" => "Avian",
"openai_compatible" => "OpenAI-compatible endpoint",
other => other,
}
Expand All @@ -759,7 +760,7 @@ impl SetupWizard {

let is_known = matches!(
current.as_str(),
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "avian"
);

if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
Expand All @@ -772,6 +773,7 @@ impl SetupWizard {
"anthropic" => return self.setup_anthropic().await,
"openai" => return self.setup_openai().await,
"ollama" => return self.setup_ollama(),
"avian" => return self.setup_avian().await,
"openai_compatible" => return self.setup_openai_compatible().await,
_ => {
return Err(SetupError::Config(format!(
Expand Down Expand Up @@ -799,6 +801,7 @@ impl SetupWizard {
"OpenAI - GPT models (direct API key)",
"Ollama - local models, no API key needed",
"OpenRouter - 200+ models via single API key",
"Avian - DeepSeek, Kimi, GLM models (direct API key)",
"OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)",
];

Expand All @@ -810,7 +813,8 @@ impl SetupWizard {
2 => self.setup_openai().await?,
3 => self.setup_ollama()?,
4 => self.setup_openrouter().await?,
5 => self.setup_openai_compatible().await?,
5 => self.setup_avian().await?,
6 => self.setup_openai_compatible().await?,
_ => return Err(SetupError::Config("Invalid provider selection".to_string())),
}

Expand Down Expand Up @@ -1014,6 +1018,19 @@ impl SetupWizard {
.await
}

/// Avian provider setup: just needs an API key.
async fn setup_avian(&mut self) -> Result<(), SetupError> {
self.setup_api_key_provider(
"avian",
"AVIAN_API_KEY",
"llm_avian_api_key",
"Avian API key",
"https://avian.io",
Some("Avian"),
)
.await
}

/// OpenAI-compatible provider setup: base URL + optional API key.
async fn setup_openai_compatible(&mut self) -> Result<(), SetupError> {
self.settings.llm_backend = Some("openai_compatible".to_string());
Expand Down Expand Up @@ -1117,6 +1134,24 @@ impl SetupWizard {
}
self.select_from_model_list(&models)?;
}
"avian" => {
let models: Vec<(String, String)> = vec![
(
"deepseek/deepseek-v3.2".into(),
"DeepSeek V3.2 (164K context)".into(),
),
(
"moonshotai/kimi-k2.5".into(),
"Kimi K2.5 (131K context)".into(),
),
("z-ai/glm-5".into(), "GLM-5 (131K context)".into()),
(
"minimax/minimax-m2.5".into(),
"MiniMax M2.5 (1M context)".into(),
),
];
Comment on lines +1138 to +1152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve readability and avoid repeated .into() calls, you can define the models as an array of string slices and then map it to a Vec<(String, String)>. This makes the data definition cleaner and more idiomatic.

Suggested change
let models: Vec<(String, String)> = vec![
(
"deepseek/deepseek-v3.2".into(),
"DeepSeek V3.2 (164K context)".into(),
),
(
"moonshotai/kimi-k2.5".into(),
"Kimi K2.5 (131K context)".into(),
),
("z-ai/glm-5".into(), "GLM-5 (131K context)".into()),
(
"minimax/minimax-m2.5".into(),
"MiniMax M2.5 (1M context)".into(),
),
];
let models: Vec<(String, String)> = [
(
"deepseek/deepseek-v3.2",
"DeepSeek V3.2 (164K context)",
),
(
"moonshotai/kimi-k2.5",
"Kimi K2.5 (131K context)",
),
("z-ai/glm-5", "GLM-5 (131K context)"),
(
"minimax/minimax-m2.5",
"MiniMax M2.5 (1M context)",
),
].iter().map(|(id, desc)| (id.to_string(), desc.to_string())).collect();

self.select_from_model_list(&models)?;
}
"openai_compatible" => {
// No standard API for listing models on arbitrary endpoints
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
Expand Down Expand Up @@ -1230,6 +1265,7 @@ impl SetupWizard {
ollama: None,
openai_compatible: None,
tinfoil: None,
avian: None,
};

match create_llm_provider(&config, session) {
Expand Down Expand Up @@ -2240,6 +2276,7 @@ impl SetupWizard {
"anthropic" => "Anthropic",
"openai" => "OpenAI",
"ollama" => "Ollama",
"avian" => "Avian",
"openai_compatible" => "OpenAI-compatible",
other => other,
};
Expand Down