mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a320a64086 | ||
|
|
13813cbb18 | ||
|
|
833738bc85 | ||
|
|
5c2ba44f12 | ||
|
|
f4d290f5ed | ||
|
|
13e000dc20 | ||
|
|
ce5961b1ec | ||
|
|
ffb9978ec6 | ||
|
|
469a252051 | ||
|
|
d195222124 | ||
|
|
5869a9cc62 | ||
|
|
1caed5a163 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo test:*)",
|
||||
"Bash(cargo fmt:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(env:*)",
|
||||
"Skill(ship)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -108,8 +108,9 @@ HEARTBEAT_NOTIFY_USER=default
|
||||
# Memory hygiene settings (automatic cleanup of stale workspace documents)
|
||||
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
|
||||
# MEMORY_HYGIENE_ENABLED=true
|
||||
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
|
||||
# Safety settings
|
||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||
|
||||
@@ -44,15 +44,42 @@ jobs:
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
clippy-windows:
|
||||
name: Clippy Windows (${{ matrix.name }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: clippy-windows-${{ matrix.name }}
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy]
|
||||
needs: [format, clippy, clippy-windows]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -51,6 +51,32 @@ jobs:
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: windows-${{ matrix.name }}
|
||||
- name: Check compilation
|
||||
run: cargo check --all --benches --tests --examples ${{ matrix.flags }}
|
||||
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
@@ -100,10 +126,10 @@ jobs:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, version-check]
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" || "${{ needs.windows-build.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.16.1](https://github.com/nearai/ironclaw/compare/v0.16.0...v0.16.1) - 2026-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- revert WASM artifact SHA256 checksums to null ([#627](https://github.com/nearai/ironclaw/pull/627))
|
||||
|
||||
## [0.16.0](https://github.com/nearai/ironclaw/compare/v0.15.0...v0.16.0) - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -2828,7 +2828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
@@ -28,6 +28,7 @@ COPY migrations/ migrations/
|
||||
COPY registry/ registry/
|
||||
COPY channels-src/ channels-src/
|
||||
COPY wit/ wit/
|
||||
COPY providers.json providers.json
|
||||
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
[
|
||||
{
|
||||
"id": "openai",
|
||||
"aliases": ["open_ai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-4o",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
"key_url": "https://platform.openai.com/api-keys",
|
||||
"display_name": "OpenAI",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"aliases": ["claude"],
|
||||
"protocol": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "ANTHROPIC_BASE_URL",
|
||||
"model_env": "ANTHROPIC_MODEL",
|
||||
"default_model": "claude-sonnet-4-20250514",
|
||||
"description": "Anthropic Claude models (direct API)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_anthropic_api_key",
|
||||
"key_url": "https://console.anthropic.com/settings/keys",
|
||||
"display_name": "Anthropic",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"aliases": [],
|
||||
"protocol": "ollama",
|
||||
"default_base_url": "http://localhost:11434",
|
||||
"base_url_env": "OLLAMA_BASE_URL",
|
||||
"model_env": "OLLAMA_MODEL",
|
||||
"default_model": "llama3",
|
||||
"description": "Local Ollama instance (no API key needed)",
|
||||
"setup": {
|
||||
"kind": "ollama",
|
||||
"display_name": "Ollama",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai_compatible",
|
||||
"aliases": ["openai-compatible", "compatible"],
|
||||
"protocol": "open_ai_completions",
|
||||
"base_url_env": "LLM_BASE_URL",
|
||||
"base_url_required": true,
|
||||
"api_key_env": "LLM_API_KEY",
|
||||
"api_key_required": false,
|
||||
"model_env": "LLM_MODEL",
|
||||
"default_model": "default",
|
||||
"extra_headers_env": "LLM_EXTRA_HEADERS",
|
||||
"description": "Custom OpenAI-compatible endpoint (vLLM, LiteLLM, etc.)",
|
||||
"setup": {
|
||||
"kind": "open_ai_compatible",
|
||||
"secret_name": "llm_compatible_api_key",
|
||||
"display_name": "OpenAI-compatible",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tinfoil",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://inference.tinfoil.sh/v1",
|
||||
"api_key_env": "TINFOIL_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "TINFOIL_MODEL",
|
||||
"default_model": "kimi-k2-5",
|
||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_tinfoil_api_key",
|
||||
"key_url": "https://tinfoil.sh",
|
||||
"display_name": "Tinfoil",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openrouter",
|
||||
"aliases": ["open_router"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "OPENROUTER_MODEL",
|
||||
"default_model": "openai/gpt-4o",
|
||||
"description": "OpenRouter multi-provider gateway (200+ models)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openrouter_api_key",
|
||||
"key_url": "https://openrouter.ai/settings/keys",
|
||||
"display_name": "OpenRouter",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "GROQ_MODEL",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"description": "Groq LPU inference (ultra-fast)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_groq_api_key",
|
||||
"key_url": "https://console.groq.com/keys",
|
||||
"display_name": "Groq",
|
||||
"can_list_models": true,
|
||||
"models_filter": "chat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nvidia",
|
||||
"aliases": ["nvidia_nim", "nim"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key_env": "NVIDIA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "NVIDIA_MODEL",
|
||||
"default_model": "meta/llama-3.3-70b-instruct",
|
||||
"description": "NVIDIA NIM API (high-performance inference)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_nvidia_api_key",
|
||||
"key_url": "https://build.nvidia.com",
|
||||
"display_name": "NVIDIA NIM",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "venice",
|
||||
"aliases": ["venice_ai", "veniceai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.venice.ai/api/v1",
|
||||
"api_key_env": "VENICE_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "VENICE_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Venice.ai privacy-focused inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_venice_api_key",
|
||||
"key_url": "https://venice.ai/settings/api",
|
||||
"display_name": "Venice.ai",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "together",
|
||||
"aliases": ["together_ai", "togetherai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.together.xyz/v1",
|
||||
"api_key_env": "TOGETHER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "TOGETHER_MODEL",
|
||||
"default_model": "meta-llama/Llama-3-70b-chat-hf",
|
||||
"description": "Together AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_together_api_key",
|
||||
"key_url": "https://api.together.ai/settings/api-keys",
|
||||
"display_name": "Together AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fireworks",
|
||||
"aliases": ["fireworks_ai"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.fireworks.ai/inference/v1",
|
||||
"api_key_env": "FIREWORKS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "FIREWORKS_MODEL",
|
||||
"default_model": "accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
"description": "Fireworks AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_fireworks_api_key",
|
||||
"key_url": "https://fireworks.ai/api-keys",
|
||||
"display_name": "Fireworks AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"aliases": ["deep_seek"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "DEEPSEEK_MODEL",
|
||||
"default_model": "deepseek-chat",
|
||||
"description": "DeepSeek inference API",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_deepseek_api_key",
|
||||
"key_url": "https://platform.deepseek.com/api_keys",
|
||||
"display_name": "DeepSeek",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cerebras",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.cerebras.ai/v1",
|
||||
"api_key_env": "CEREBRAS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "CEREBRAS_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Cerebras wafer-scale inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_cerebras_api_key",
|
||||
"key_url": "https://cloud.cerebras.ai",
|
||||
"display_name": "Cerebras",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sambanova",
|
||||
"aliases": ["samba_nova"],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.sambanova.ai/v1",
|
||||
"api_key_env": "SAMBANOVA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "SAMBANOVA_MODEL",
|
||||
"default_model": "Meta-Llama-3.1-70B-Instruct",
|
||||
"description": "SambaNova Cloud inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_sambanova_api_key",
|
||||
"key_url": "https://cloud.sambanova.ai/apis",
|
||||
"display_name": "SambaNova",
|
||||
"can_list_models": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||
"sha256": "27d83724c22cac2658c5f4e04dfe761206270e65d599e8f08cc8148c3d9bbe86"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": "600fdb6f25f42bd635d3cf28217778c780e781b780c7250a57bebcf889616209"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": "1c3028052f680e2efa7d857d50bcb57dbc171ad197d2527875b9c3cd22f0c830"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
||||
"sha256": "33ba508576bdcf757ba5d27a1c94fb9f3546bfe489adf68e5fb17db3b2db7bac"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
||||
"sha256": "d1305ad85a3722a1cfa7dbc8449ebb6c277083d887c513e6e4dd84814637dbcd"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
||||
"sha256": "f0899b243cb175fcfc07f5a431abb28fac73fc6893c9932d32ce2bd17bc72763"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
||||
"sha256": "f236cd8b63aafc95fa5c7f6c9f4ef05d34273d34b4afeb3fde6af51f54fa1350"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
||||
"sha256": "37cecb81190703b010df11ad3b507ade570fa486c891b24f48105c34bc7a6f10"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
||||
"sha256": "36d5116c7faaaf34b91f98e92573ed230ce0d85e261f05a996a02d14ae4715c4"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
||||
"sha256": "77c966f0e18faa2b43361ad8abe90144d53b163272e96d2ed5106f480e698d64"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
||||
"sha256": "68365b764f2366142d1f5388189ab1bd7f826f4ac6540547efc6750bde1591d3"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
||||
"sha256": "600fdb6f25f42bd635d3cf28217778c780e781b780c7250a57bebcf889616209"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
||||
"sha256": "1c3028052f680e2efa7d857d50bcb57dbc171ad197d2527875b9c3cd22f0c830"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
||||
"sha256": "8e62c9c3efaa90db92dbf421289cd9a8ba83a64613481d0f2bf9070f0403e801"
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -17,6 +17,16 @@ use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
||||
use crate::tools::redact_params;
|
||||
|
||||
/// Represents image generation sentinel data in tool output.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ImageGeneratedSentinel<'a> {
|
||||
#[serde(rename = "type")]
|
||||
ty: &'a str,
|
||||
data: &'a str,
|
||||
media_type: &'a str,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
/// Result of the agentic loop execution.
|
||||
pub(super) enum AgenticLoopResult {
|
||||
/// Completed with a response.
|
||||
@@ -640,6 +650,28 @@ impl Agent {
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Check for image_generated sentinel and emit SSE event
|
||||
if let Ok(sentinel) =
|
||||
serde_json::from_str::<ImageGeneratedSentinel>(output)
|
||||
&& sentinel.ty == "image_generated"
|
||||
{
|
||||
let data_url = format!(
|
||||
"data:{};base64,{}",
|
||||
sentinel.media_type, sentinel.data
|
||||
);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ImageGenerated {
|
||||
data_url,
|
||||
path: sentinel.path.to_string(),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Record result in thread
|
||||
|
||||
@@ -164,6 +164,7 @@ impl HeartbeatRunner {
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
||||
"heartbeat: memory hygiene deleted stale documents"
|
||||
);
|
||||
}
|
||||
|
||||
+29
-2
@@ -16,7 +16,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::llm::{ChatMessage, ToolCall};
|
||||
use crate::llm::{ChatMessage, ImageAttachment, ToolCall};
|
||||
|
||||
/// A session containing one or more threads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -250,6 +250,22 @@ impl Thread {
|
||||
&mut self.turns[turn_number]
|
||||
}
|
||||
|
||||
/// Start a new turn with user input and image attachments.
|
||||
pub fn start_turn_with_images(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
images: Vec<ImageAttachment>,
|
||||
) -> &mut Turn {
|
||||
let turn_number = self.turns.len();
|
||||
let mut turn = Turn::new(turn_number, user_input);
|
||||
turn.images = images;
|
||||
self.turns.push(turn);
|
||||
self.state = ThreadState::Processing;
|
||||
self.updated_at = Utc::now();
|
||||
// turn_number was len() before push, so it's a valid index after push
|
||||
&mut self.turns[turn_number]
|
||||
}
|
||||
|
||||
/// Complete the current turn with a response.
|
||||
pub fn complete_turn(&mut self, response: impl Into<String>) {
|
||||
if let Some(turn) = self.turns.last_mut() {
|
||||
@@ -320,7 +336,14 @@ impl Thread {
|
||||
pub fn messages(&self) -> Vec<ChatMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for turn in &self.turns {
|
||||
messages.push(ChatMessage::user(&turn.user_input));
|
||||
if turn.images.is_empty() {
|
||||
messages.push(ChatMessage::user(&turn.user_input));
|
||||
} else {
|
||||
messages.push(ChatMessage::user_with_images(
|
||||
&turn.user_input,
|
||||
turn.images.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(ref response) = turn.response {
|
||||
messages.push(ChatMessage::assistant(response));
|
||||
}
|
||||
@@ -407,6 +430,9 @@ pub struct Turn {
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Error message (if failed).
|
||||
pub error: Option<String>,
|
||||
/// Images attached to this turn's user input.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub images: Vec<ImageAttachment>,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
@@ -421,6 +447,7 @@ impl Turn {
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,11 @@ impl Agent {
|
||||
.threads
|
||||
.get_mut(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
thread.start_turn(content);
|
||||
if message.images.is_empty() {
|
||||
thread.start_turn(content);
|
||||
} else {
|
||||
thread.start_turn_with_images(content, message.images.clone());
|
||||
}
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -1414,9 +1414,11 @@ mod tests {
|
||||
assert!(r.result.is_ok(), "Tool should succeed");
|
||||
}
|
||||
// Parallel should complete well under the sequential 600ms threshold.
|
||||
// Use a generous bound (800ms) to avoid flaky failures on slow CI runners,
|
||||
// while still proving parallelism (sequential would be >= 600ms on any machine).
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(500),
|
||||
"Parallel execution took {:?}, expected < 500ms",
|
||||
elapsed < Duration::from_millis(800),
|
||||
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
+40
-15
@@ -368,21 +368,6 @@ impl AppBuilder {
|
||||
.embeddings
|
||||
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
|
||||
|
||||
// Warn if libSQL backend is used with non-1536 embedding dimension.
|
||||
if self.config.database.backend == crate::config::DatabaseBackend::LibSql
|
||||
&& self.config.embeddings.enabled
|
||||
&& self.config.embeddings.dimension != 1536
|
||||
{
|
||||
tracing::warn!(
|
||||
configured_dimension = self.config.embeddings.dimension,
|
||||
"Embedding dimension {} is not 1536. The libSQL schema uses \
|
||||
F32_BLOB(1536) which requires exactly 1536 dimensions. \
|
||||
Embedding storage will fail. Use PostgreSQL or set \
|
||||
EMBEDDING_DIMENSION=1536.",
|
||||
self.config.embeddings.dimension
|
||||
);
|
||||
}
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
let mut ws = Workspace::new_with_db("default", db.clone());
|
||||
@@ -391,6 +376,46 @@ impl AppBuilder {
|
||||
}
|
||||
let ws = Arc::new(ws);
|
||||
tools.register_memory_tools(Arc::clone(&ws));
|
||||
|
||||
// Register image tools if image generation models are available
|
||||
match llm.list_models().await {
|
||||
Ok(models) => {
|
||||
if let Some(image_model) =
|
||||
crate::llm::image_models::suggest_image_model(&models)
|
||||
{
|
||||
tools.register_image_tools(self.config.llm.nearai.clone(), Arc::clone(&ws));
|
||||
tracing::info!(
|
||||
"Image generation tools registered (model: {})",
|
||||
image_model
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"No image generation models detected in available models: {:?}",
|
||||
models
|
||||
);
|
||||
}
|
||||
|
||||
// Register vision analysis tool if vision models are available
|
||||
if let Some(vision_model) =
|
||||
crate::llm::vision_models::suggest_vision_model(&models)
|
||||
{
|
||||
tools.register_vision_tools(Arc::clone(&ws));
|
||||
tracing::info!(
|
||||
"Image analysis tool registered (vision model: {})",
|
||||
vision_model
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("No vision-capable models detected in available models");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to list available models for image tool registration: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(ws)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -9,6 +9,7 @@ use futures::Stream;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ChannelError;
|
||||
use crate::llm::ImageAttachment;
|
||||
|
||||
/// A message received from an external channel.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -29,6 +30,8 @@ pub struct IncomingMessage {
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Channel-specific metadata.
|
||||
pub metadata: serde_json::Value,
|
||||
/// Images attached to this message.
|
||||
pub images: Vec<ImageAttachment>,
|
||||
}
|
||||
|
||||
impl IncomingMessage {
|
||||
@@ -47,6 +50,7 @@ impl IncomingMessage {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +71,12 @@ impl IncomingMessage {
|
||||
self.user_name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach image attachments.
|
||||
pub fn with_images(mut self, images: Vec<ImageAttachment>) -> Self {
|
||||
self.images = images;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of incoming messages.
|
||||
@@ -163,6 +173,8 @@ pub enum StatusUpdate {
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
/// An image was generated or edited by a tool.
|
||||
ImageGenerated { data_url: String, path: String },
|
||||
}
|
||||
|
||||
impl StatusUpdate {
|
||||
|
||||
@@ -585,6 +585,9 @@ impl Channel for ReplChannel {
|
||||
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
||||
}
|
||||
}
|
||||
StatusUpdate::ImageGenerated { path, .. } => {
|
||||
eprintln!(" \x1b[36m[image]\x1b[0m {path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -153,7 +153,16 @@ impl WasmChannelRuntime {
|
||||
// Enable persistent compilation cache. Wasmtime serializes compiled native
|
||||
// code to disk (~/.cache/wasmtime by default), so subsequent startups
|
||||
// deserialize instead of recompiling — typically 10-50x faster.
|
||||
if let Err(e) = wasmtime_config.cache_config_load_default() {
|
||||
//
|
||||
// On Windows, each Engine gets its own cache subdirectory to avoid
|
||||
// OS error 33 (ERROR_LOCK_VIOLATION) when multiple engines share the
|
||||
// default cache and Windows holds exclusive locks on memory-mapped
|
||||
// files. See #448.
|
||||
if let Err(e) = crate::tools::wasm::enable_compilation_cache(
|
||||
&mut wasmtime_config,
|
||||
"channels",
|
||||
config.cache_dir.as_deref(),
|
||||
) {
|
||||
tracing::warn!("Failed to enable wasmtime compilation cache: {}", e);
|
||||
}
|
||||
|
||||
|
||||
@@ -2591,6 +2591,11 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::ImageGenerated { path, .. } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Status,
|
||||
message: format!("Image generated: {}", path),
|
||||
metadata_json,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,19 @@ impl Channel for GatewayChannel {
|
||||
success,
|
||||
message,
|
||||
},
|
||||
StatusUpdate::ImageGenerated { data_url, path } => {
|
||||
tracing::debug!(
|
||||
path = %path,
|
||||
data_url_len = data_url.len(),
|
||||
thread_id = ?thread_id,
|
||||
"Broadcasting ImageGenerated SSE event"
|
||||
);
|
||||
SseEvent::ImageGenerated {
|
||||
data_url,
|
||||
path,
|
||||
thread_id,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.state.sse.broadcast(event);
|
||||
|
||||
@@ -247,6 +247,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
|
||||
tool_call_id: None,
|
||||
name: m.name.clone(),
|
||||
tool_calls: None,
|
||||
images: Vec::new(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
+33
-12
@@ -43,6 +43,7 @@ use crate::channels::web::types::*;
|
||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||
use crate::db::Database;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::llm::ImageAttachment;
|
||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace::Workspace;
|
||||
@@ -626,6 +627,17 @@ async fn chat_send_handler(
|
||||
msg = msg.with_metadata(serde_json::json!({"thread_id": thread_id}));
|
||||
}
|
||||
|
||||
// Convert image data to ImageAttachment
|
||||
let images: Vec<ImageAttachment> = req
|
||||
.images
|
||||
.into_iter()
|
||||
.map(|img| ImageAttachment {
|
||||
media_type: img.media_type,
|
||||
data: img.data,
|
||||
})
|
||||
.collect();
|
||||
msg = msg.with_images(images);
|
||||
|
||||
let msg_id = msg.id;
|
||||
tracing::debug!(
|
||||
"[chat_send_handler] Created message id={}, content={:?}",
|
||||
@@ -951,18 +963,25 @@ async fn chat_history_handler(
|
||||
tool_calls: t
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCallInfo {
|
||||
name: tc.name.clone(),
|
||||
has_result: tc.result.is_some(),
|
||||
has_error: tc.error.is_some(),
|
||||
result_preview: tc.result.as_ref().map(|r| {
|
||||
let s = match r {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
truncate_preview(&s, 500)
|
||||
}),
|
||||
error: tc.error.clone(),
|
||||
.map(|tc| {
|
||||
// Image tools need full results (large base64 data), don't truncate
|
||||
let limit = match tc.name.as_str() {
|
||||
"image_generate" | "image_edit" | "image_analyze" => usize::MAX,
|
||||
_ => 500,
|
||||
};
|
||||
ToolCallInfo {
|
||||
name: tc.name.clone(),
|
||||
has_result: tc.result.is_some(),
|
||||
has_error: tc.error.is_some(),
|
||||
result_preview: tc.result.as_ref().map(|r| {
|
||||
let s = match r {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
truncate_preview(&s, limit)
|
||||
}),
|
||||
error: tc.error.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -2319,6 +2338,7 @@ async fn gateway_status_handler(
|
||||
.unwrap_or(false);
|
||||
|
||||
Json(GatewayStatusResponse {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sse_connections,
|
||||
ws_connections,
|
||||
total_connections: sse_connections + ws_connections,
|
||||
@@ -2340,6 +2360,7 @@ struct ModelUsageEntry {
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct GatewayStatusResponse {
|
||||
version: String,
|
||||
sse_connections: u64,
|
||||
ws_connections: u64,
|
||||
total_connections: u64,
|
||||
|
||||
@@ -55,6 +55,10 @@ impl SseManager {
|
||||
|
||||
/// Broadcast an event to all connected clients.
|
||||
pub fn broadcast(&self, event: SseEvent) {
|
||||
// Log image events for debugging
|
||||
if matches!(&event, SseEvent::ImageGenerated { .. }) {
|
||||
tracing::debug!("Broadcasting image_generated SSE event to all connected clients");
|
||||
}
|
||||
// Ignore send errors (no receivers is fine)
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
@@ -143,6 +147,7 @@ impl SseManager {
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
};
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ const SLASH_COMMANDS = [
|
||||
let _slashSelected = -1;
|
||||
let _slashMatches = [];
|
||||
|
||||
// --- Image Attachments ---
|
||||
let stagedImages = []; // Array of { media_type, data, previewUrl }
|
||||
|
||||
// --- Tool Activity State ---
|
||||
let _activeGroup = null;
|
||||
let _activeToolCards = {};
|
||||
@@ -113,6 +116,78 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
|
||||
}
|
||||
})();
|
||||
|
||||
// --- Image Attachment Handlers ---
|
||||
|
||||
// Handle file picker selection
|
||||
document.getElementById('image-input').addEventListener('change', (e) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
for (let file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const base64Data = evt.target.result.split(',')[1]; // Remove data URL prefix
|
||||
stagedImages.push({
|
||||
media_type: file.type,
|
||||
data: base64Data,
|
||||
previewUrl: evt.target.result,
|
||||
});
|
||||
renderImagePreviews();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset file input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Handle paste event
|
||||
document.getElementById('chat-input').addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData.items;
|
||||
for (let item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
const base64Data = evt.target.result.split(',')[1];
|
||||
stagedImages.push({
|
||||
media_type: item.type,
|
||||
data: base64Data,
|
||||
previewUrl: evt.target.result,
|
||||
});
|
||||
renderImagePreviews();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function renderImagePreviews() {
|
||||
const strip = document.getElementById('image-preview-strip');
|
||||
if (stagedImages.length === 0) {
|
||||
strip.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
strip.style.display = 'flex';
|
||||
strip.innerHTML = '';
|
||||
stagedImages.forEach((img, idx) => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'image-preview';
|
||||
container.innerHTML = `
|
||||
<img src="${img.previewUrl}" alt="Preview">
|
||||
<button class="image-preview-remove" onclick="removeImage(${idx})" title="Remove">×</button>
|
||||
`;
|
||||
strip.appendChild(container);
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage(idx) {
|
||||
stagedImages.splice(idx, 1);
|
||||
renderImagePreviews();
|
||||
}
|
||||
|
||||
// --- API helper ---
|
||||
|
||||
function apiFetch(path, options) {
|
||||
@@ -315,6 +390,17 @@ function connectSSE() {
|
||||
setToolCardOutput(data.name, data.preview);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('image_generated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('Received image_generated event:', { thread_id: data.thread_id, path: data.path, data_url_len: data.data_url ? data.data_url.length : 0 });
|
||||
if (!isCurrentThread(data.thread_id)) {
|
||||
console.log('Image event ignored: not current thread', { currentThreadId, eventThreadId: data.thread_id });
|
||||
return;
|
||||
}
|
||||
console.log('Adding generated image to chat');
|
||||
addGeneratedImage(data.data_url, data.path);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('stream_chunk', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
@@ -430,19 +516,28 @@ function sendMessage() {
|
||||
return;
|
||||
}
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
if (!content && stagedImages.length === 0) return;
|
||||
|
||||
addMessage('user', content);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
input.focus();
|
||||
|
||||
const images = stagedImages.map(img => ({
|
||||
media_type: img.media_type,
|
||||
data: img.data,
|
||||
}));
|
||||
|
||||
apiFetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { content, thread_id: currentThreadId || undefined },
|
||||
body: { content, thread_id: currentThreadId || undefined, images },
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send: ' + err.message);
|
||||
});
|
||||
|
||||
// Clear staged images after sending
|
||||
stagedImages = [];
|
||||
renderImagePreviews();
|
||||
}
|
||||
|
||||
function enableChatInput() {
|
||||
@@ -858,6 +953,30 @@ function finalizeActivityGroup() {
|
||||
_activeToolCards = {};
|
||||
}
|
||||
|
||||
function addGeneratedImage(dataUrl, path) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
console.log('addGeneratedImage called', { dataUrl_len: dataUrl ? dataUrl.length : 0, path });
|
||||
const card = document.createElement('div');
|
||||
card.className = 'generated-image-card';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dataUrl;
|
||||
img.alt = 'Generated image';
|
||||
img.className = 'generated-image';
|
||||
img.onerror = () => console.error('Failed to load image from data URL:', dataUrl.substring(0, 100));
|
||||
img.onload = () => console.log('Image loaded successfully from data URL');
|
||||
|
||||
const pathLabel = document.createElement('div');
|
||||
pathLabel.className = 'generated-image-path';
|
||||
pathLabel.textContent = 'Saved to: ' + path;
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(pathLabel);
|
||||
container.appendChild(card);
|
||||
console.log('Image card appended to DOM');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function showApproval(data) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
const card = document.createElement('div');
|
||||
@@ -1223,10 +1342,33 @@ function createToolCallsSummaryElement(toolCalls) {
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
if (tc.result_preview) {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'tool-call-preview';
|
||||
preview.textContent = tc.result_preview;
|
||||
item.appendChild(preview);
|
||||
// Check if this is an image result
|
||||
try {
|
||||
const parsed = JSON.parse(tc.result_preview);
|
||||
if (parsed.type === 'image_generated' && parsed.data && parsed.media_type) {
|
||||
const dataUrl = `data:${parsed.media_type};base64,${parsed.data}`;
|
||||
const imgDiv = document.createElement('div');
|
||||
imgDiv.className = 'generated-image-card';
|
||||
const img = document.createElement('img');
|
||||
img.src = dataUrl;
|
||||
img.alt = 'Generated image';
|
||||
img.className = 'generated-image';
|
||||
imgDiv.appendChild(img);
|
||||
item.appendChild(imgDiv);
|
||||
} else {
|
||||
// Regular text result
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'tool-call-preview';
|
||||
preview.textContent = tc.result_preview;
|
||||
item.appendChild(preview);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as text
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'tool-call-preview';
|
||||
preview.textContent = tc.result_preview;
|
||||
item.appendChild(preview);
|
||||
}
|
||||
}
|
||||
if (tc.error) {
|
||||
const errDiv = document.createElement('div');
|
||||
@@ -3294,6 +3436,12 @@ function fetchGatewayStatus() {
|
||||
var popover = document.getElementById('gateway-popover');
|
||||
var html = '';
|
||||
|
||||
// Version
|
||||
if (data.version) {
|
||||
html += '<div class="gw-section-label">IronClaw v' + escapeHtml(data.version) + '</div>';
|
||||
html += '<div class="gw-divider"></div>';
|
||||
}
|
||||
|
||||
// Connection info
|
||||
html += '<div class="gw-section-label">Connections</div>';
|
||||
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
|
||||
@@ -129,7 +129,10 @@
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div class="image-preview-strip" id="image-preview-strip" style="display:none;"></div>
|
||||
<div class="chat-input">
|
||||
<input type="file" id="image-input" accept="image/*" multiple style="display:none">
|
||||
<button id="attach-btn" class="attach-btn" title="Attach image" onclick="document.getElementById('image-input').click()">📎</button>
|
||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
|
||||
@@ -1093,6 +1093,37 @@ body {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Generated image card */
|
||||
.generated-image-card {
|
||||
align-self: flex-start;
|
||||
width: 50%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.generated-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.generated-image-path {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Tool calls summary (persisted between user/assistant messages) */
|
||||
.tool-calls-summary {
|
||||
background: var(--bg-secondary);
|
||||
@@ -1325,6 +1356,73 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.attach-btn:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.image-preview-strip {
|
||||
display: flex;
|
||||
padding: 12px 16px 0 16px;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-preview-remove {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.image-preview-remove:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Memory Tab */
|
||||
.memory-container {
|
||||
flex: 1;
|
||||
|
||||
@@ -5,10 +5,18 @@ use uuid::Uuid;
|
||||
|
||||
// --- Chat ---
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ImageData {
|
||||
pub media_type: String,
|
||||
pub data: String, // base64-encoded
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub content: String,
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub images: Vec<ImageData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -225,6 +233,17 @@ pub enum SseEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
},
|
||||
|
||||
/// An image was generated or edited.
|
||||
#[serde(rename = "image_generated")]
|
||||
ImageGenerated {
|
||||
/// Base64 data URL: "data:image/png;base64,..."
|
||||
data_url: String,
|
||||
/// Workspace path where the image is saved.
|
||||
path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Memory ---
|
||||
@@ -606,6 +625,8 @@ pub enum WsClientMessage {
|
||||
Message {
|
||||
content: String,
|
||||
thread_id: Option<String>,
|
||||
#[serde(default)]
|
||||
images: Vec<ImageData>,
|
||||
},
|
||||
/// Approve or deny a pending tool execution.
|
||||
#[serde(rename = "approval")]
|
||||
@@ -673,6 +694,7 @@ impl WsServerMessage {
|
||||
SseEvent::JobStatus { .. } => "job_status",
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
};
|
||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||
WsServerMessage::Event {
|
||||
@@ -791,9 +813,14 @@ mod tests {
|
||||
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
|
||||
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||
match msg {
|
||||
WsClientMessage::Message { content, thread_id } => {
|
||||
WsClientMessage::Message {
|
||||
content,
|
||||
thread_id,
|
||||
images,
|
||||
} => {
|
||||
assert_eq!(content, "hello");
|
||||
assert_eq!(thread_id.as_deref(), Some("t1"));
|
||||
assert!(images.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Message variant"),
|
||||
}
|
||||
@@ -804,9 +831,14 @@ mod tests {
|
||||
let json = r#"{"type":"message","content":"hi"}"#;
|
||||
let msg: WsClientMessage = serde_json::from_str(json).unwrap();
|
||||
match msg {
|
||||
WsClientMessage::Message { content, thread_id } => {
|
||||
WsClientMessage::Message {
|
||||
content,
|
||||
thread_id,
|
||||
images,
|
||||
} => {
|
||||
assert_eq!(content, "hi");
|
||||
assert!(thread_id.is_none());
|
||||
assert!(images.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Message variant"),
|
||||
}
|
||||
|
||||
+18
-1
@@ -22,6 +22,7 @@ use crate::agent::submission::Submission;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::{WsClientMessage, WsServerMessage};
|
||||
use crate::llm::ImageAttachment;
|
||||
|
||||
/// Tracks active WebSocket connections.
|
||||
pub struct WsConnectionTracker {
|
||||
@@ -156,12 +157,26 @@ async fn handle_client_message(
|
||||
direct_tx: &mpsc::Sender<WsServerMessage>,
|
||||
) {
|
||||
match msg {
|
||||
WsClientMessage::Message { content, thread_id } => {
|
||||
WsClientMessage::Message {
|
||||
content,
|
||||
thread_id,
|
||||
images,
|
||||
} => {
|
||||
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
|
||||
if let Some(ref tid) = thread_id {
|
||||
incoming = incoming.with_thread(tid);
|
||||
}
|
||||
|
||||
// Convert image data to ImageAttachment
|
||||
let image_attachments: Vec<ImageAttachment> = images
|
||||
.into_iter()
|
||||
.map(|img| ImageAttachment {
|
||||
media_type: img.media_type,
|
||||
data: img.data,
|
||||
})
|
||||
.collect();
|
||||
incoming = incoming.with_images(image_attachments);
|
||||
|
||||
let tx_guard = state.msg_tx.read().await;
|
||||
if let Some(ref tx) = *tx_guard {
|
||||
if tx.send(incoming).await.is_err() {
|
||||
@@ -349,6 +364,7 @@ mod tests {
|
||||
WsClientMessage::Message {
|
||||
content: "hello agent".to_string(),
|
||||
thread_id: Some("t1".to_string()),
|
||||
images: vec![],
|
||||
},
|
||||
&state,
|
||||
"user1",
|
||||
@@ -373,6 +389,7 @@ mod tests {
|
||||
WsClientMessage::Message {
|
||||
content: "hello".to_string(),
|
||||
thread_id: None,
|
||||
images: vec![],
|
||||
},
|
||||
&state,
|
||||
"user1",
|
||||
|
||||
+6
-2
@@ -86,7 +86,7 @@ pub enum Command {
|
||||
/// Interactive onboarding wizard
|
||||
#[command(
|
||||
about = "Run interactive setup wizard",
|
||||
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels"
|
||||
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels\n ironclaw onboard --provider-only # Change LLM provider and model"
|
||||
)]
|
||||
Onboard {
|
||||
/// Skip authentication (use existing session)
|
||||
@@ -94,8 +94,12 @@ pub enum Command {
|
||||
skip_auth: bool,
|
||||
|
||||
/// Reconfigure channels only
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with = "provider_only")]
|
||||
channels_only: bool,
|
||||
|
||||
/// Reconfigure LLM provider and model only
|
||||
#[arg(long, conflicts_with = "channels_only")]
|
||||
provider_only: bool,
|
||||
},
|
||||
|
||||
/// Manage configuration settings
|
||||
|
||||
+13
-5
@@ -10,8 +10,10 @@ use crate::error::ConfigError;
|
||||
pub struct HygieneConfig {
|
||||
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
|
||||
pub enabled: bool,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
|
||||
pub retention_days: u32,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_DAILY_RETENTION_DAYS` (default: 30).
|
||||
pub daily_retention_days: u32,
|
||||
/// Days before `conversations/` documents are deleted. Env: `MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS` (default: 7).
|
||||
pub conversation_retention_days: u32,
|
||||
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
|
||||
pub cadence_hours: u32,
|
||||
}
|
||||
@@ -20,7 +22,8 @@ impl Default for HygieneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
retention_days: 30,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 12,
|
||||
}
|
||||
}
|
||||
@@ -30,7 +33,11 @@ impl HygieneConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
|
||||
retention_days: parse_optional_env("MEMORY_HYGIENE_RETENTION_DAYS", 30)?,
|
||||
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
|
||||
conversation_retention_days: parse_optional_env(
|
||||
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
|
||||
7,
|
||||
)?,
|
||||
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
|
||||
})
|
||||
}
|
||||
@@ -40,7 +47,8 @@ impl HygieneConfig {
|
||||
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
|
||||
crate::workspace::hygiene::HygieneConfig {
|
||||
enabled: self.enabled,
|
||||
retention_days: self.retention_days,
|
||||
daily_retention_days: self.daily_retention_days,
|
||||
conversation_retention_days: self.conversation_retention_days,
|
||||
cadence_hours: self.cadence_hours,
|
||||
state_dir: ironclaw_base_dir(),
|
||||
}
|
||||
|
||||
+407
-292
@@ -5,141 +5,49 @@ use secrecy::SecretString;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
use crate::llm::session::SessionConfig;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Which LLM backend to use.
|
||||
/// Resolved configuration for a registry-based provider.
|
||||
///
|
||||
/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem.
|
||||
/// Users can override with `LLM_BACKEND` env var to use their own API keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LlmBackend {
|
||||
/// NEAR AI proxy (default) -- session or API key auth
|
||||
#[default]
|
||||
NearAi,
|
||||
/// Direct OpenAI API
|
||||
OpenAi,
|
||||
/// Direct Anthropic API
|
||||
Anthropic,
|
||||
/// Local Ollama instance
|
||||
Ollama,
|
||||
/// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together)
|
||||
OpenAiCompatible,
|
||||
/// Tinfoil private inference
|
||||
Tinfoil,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for LlmBackend {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"nearai" | "near_ai" | "near" => Ok(Self::NearAi),
|
||||
"openai" | "open_ai" => Ok(Self::OpenAi),
|
||||
"anthropic" | "claude" => Ok(Self::Anthropic),
|
||||
"ollama" => Ok(Self::Ollama),
|
||||
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
||||
"tinfoil" => Ok(Self::Tinfoil),
|
||||
_ => Err(format!(
|
||||
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LlmBackend {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NearAi => write!(f, "nearai"),
|
||||
Self::OpenAi => write!(f, "openai"),
|
||||
Self::Anthropic => write!(f, "anthropic"),
|
||||
Self::Ollama => write!(f, "ollama"),
|
||||
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
||||
Self::Tinfoil => write!(f, "tinfoil"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LlmBackend {
|
||||
/// The environment variable that configures the model name for this backend.
|
||||
///
|
||||
/// Used by both `LlmConfig::resolve()` (reads the var) and the setup wizard
|
||||
/// (writes the var to `.env`). Centralised here so the two stay in sync.
|
||||
pub fn model_env_var(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NearAi => "NEARAI_MODEL",
|
||||
Self::OpenAi => "OPENAI_MODEL",
|
||||
Self::Anthropic => "ANTHROPIC_MODEL",
|
||||
Self::Ollama => "OLLAMA_MODEL",
|
||||
Self::OpenAiCompatible => "LLM_MODEL",
|
||||
Self::Tinfoil => "TINFOIL_MODEL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for direct OpenAI API access.
|
||||
/// This single struct replaces what used to be five separate config types
|
||||
/// (`OpenAiDirectConfig`, `AnthropicDirectConfig`, `OllamaConfig`,
|
||||
/// `OpenAiCompatibleConfig`, `TinfoilConfig`). The `protocol` field
|
||||
/// determines which rig-core client constructor to use.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiDirectConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
/// Optional base URL override (e.g. for proxies like VibeProxy).
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for direct Anthropic API access.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicDirectConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
/// Optional base URL override (e.g. for proxies like VibeProxy).
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for local Ollama.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OllamaConfig {
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Configuration for any OpenAI-compatible endpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiCompatibleConfig {
|
||||
pub base_url: String,
|
||||
pub struct RegistryProviderConfig {
|
||||
/// Which API protocol to use (determines the rig-core client).
|
||||
pub protocol: ProviderProtocol,
|
||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||
pub provider_id: String,
|
||||
/// API key (optional for some providers like Ollama).
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Base URL for the API endpoint.
|
||||
pub base_url: String,
|
||||
/// Model identifier.
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every LLM request.
|
||||
/// Parsed from `LLM_EXTRA_HEADERS` env var (format: `Key:Value,Key2:Value2`).
|
||||
/// Extra HTTP headers injected into every request.
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Configuration for Tinfoil private inference.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TinfoilConfig {
|
||||
pub api_key: SecretString,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
///
|
||||
/// NEAR AI remains the default backend. Users can switch to other providers
|
||||
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
|
||||
/// NearAI remains the default backend with its own config struct (session auth).
|
||||
/// All other providers are resolved through the provider registry, producing
|
||||
/// a generic `RegistryProviderConfig`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmConfig {
|
||||
/// Which backend to use (default: NearAi)
|
||||
pub backend: LlmBackend,
|
||||
/// NEAR AI config (always populated for NEAR AI embeddings, etc.)
|
||||
/// Backend identifier (e.g., "nearai", "openai", "groq", "tinfoil").
|
||||
pub backend: String,
|
||||
/// Session manager configuration (auth URL, token persistence path).
|
||||
/// Used by the NearAI provider for OAuth/session-token auth.
|
||||
pub session: SessionConfig,
|
||||
/// NEAR AI config (always populated, also used for embeddings).
|
||||
pub nearai: NearAiConfig,
|
||||
/// Direct OpenAI config (populated when backend=openai)
|
||||
pub openai: Option<OpenAiDirectConfig>,
|
||||
/// Direct Anthropic config (populated when backend=anthropic)
|
||||
pub anthropic: Option<AnthropicDirectConfig>,
|
||||
/// Ollama config (populated when backend=ollama)
|
||||
pub ollama: Option<OllamaConfig>,
|
||||
/// OpenAI-compatible config (populated when backend=openai_compatible)
|
||||
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||
/// Tinfoil config (populated when backend=tinfoil)
|
||||
pub tinfoil: Option<TinfoilConfig>,
|
||||
/// Resolved provider config for registry-based providers.
|
||||
/// `None` when backend is "nearai".
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
}
|
||||
|
||||
/// NEAR AI configuration.
|
||||
@@ -148,67 +56,47 @@ pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main model if not set.
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API.
|
||||
/// Default: `https://private.near.ai` (session token) or `https://cloud-api.near.ai` (API key)
|
||||
pub base_url: String,
|
||||
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
|
||||
pub auth_base_url: String,
|
||||
/// Path to session file (default: ~/.ironclaw/session.json)
|
||||
pub session_path: PathBuf,
|
||||
/// API key for NEAR AI Cloud. When set, uses API key auth; otherwise uses session token auth.
|
||||
/// API key for NEAR AI Cloud.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Optional fallback model for failover (default: None).
|
||||
/// When set, a secondary provider is created with this model and wrapped
|
||||
/// in a `FailoverProvider` so transient errors on the primary model
|
||||
/// automatically fall through to the fallback.
|
||||
/// Optional fallback model for failover.
|
||||
pub fallback_model: Option<String>,
|
||||
/// Maximum number of retries for transient errors (default: 3).
|
||||
/// With the default of 3, the provider makes up to 4 total attempts
|
||||
/// (1 initial + 3 retries) before giving up.
|
||||
pub max_retries: u32,
|
||||
/// Consecutive transient failures before the circuit breaker opens.
|
||||
/// None = disabled (default). E.g. 5 means after 5 consecutive failures
|
||||
/// all requests are rejected until recovery timeout elapses.
|
||||
/// Consecutive failures before circuit breaker opens. None = disabled.
|
||||
pub circuit_breaker_threshold: Option<u32>,
|
||||
/// How long (seconds) the circuit stays open before allowing a probe (default: 30).
|
||||
/// Seconds the circuit stays open before probing (default: 30).
|
||||
pub circuit_breaker_recovery_secs: u64,
|
||||
/// Enable in-memory response caching for `complete()` calls.
|
||||
/// Saves tokens on repeated prompts within a session. Default: false.
|
||||
/// Enable in-memory response caching. Default: false.
|
||||
pub response_cache_enabled: bool,
|
||||
/// TTL in seconds for cached responses (default: 3600 = 1 hour).
|
||||
/// TTL in seconds for cached responses (default: 3600).
|
||||
pub response_cache_ttl_secs: u64,
|
||||
/// Max cached responses before LRU eviction (default: 1000).
|
||||
pub response_cache_max_entries: usize,
|
||||
/// Cooldown duration in seconds for the failover provider (default: 300).
|
||||
/// When a provider accumulates enough consecutive failures it is skipped
|
||||
/// for this many seconds.
|
||||
/// Cooldown duration in seconds for failover (default: 300).
|
||||
pub failover_cooldown_secs: u64,
|
||||
/// Number of consecutive retryable failures before a provider enters
|
||||
/// cooldown (default: 3).
|
||||
/// Consecutive failures before failover cooldown (default: 3).
|
||||
pub failover_cooldown_threshold: u32,
|
||||
/// Enable cascade mode for smart routing: when a moderate-complexity task
|
||||
/// gets an uncertain response from the cheap model, re-send to primary.
|
||||
/// Default: true.
|
||||
/// Enable cascade mode for smart routing. Default: true.
|
||||
pub smart_routing_cascade: bool,
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
/// Create a test-friendly config without reading env vars.
|
||||
///
|
||||
/// Uses NearAi backend with dummy values. The LLM provider is replaced
|
||||
/// by `TraceLlm` via `AppBuilder::with_llm()`, so these values are unused.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
backend: LlmBackend::NearAi,
|
||||
backend: "nearai".to_string(),
|
||||
session: SessionConfig {
|
||||
auth_base_url: "http://localhost:0".to_string(),
|
||||
session_path: PathBuf::from("/tmp/ironclaw-test-session.json"),
|
||||
},
|
||||
nearai: NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "http://localhost:0".to_string(),
|
||||
auth_base_url: "http://localhost:0".to_string(),
|
||||
session_path: PathBuf::from("/tmp/ironclaw-test-session.json"),
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 0,
|
||||
@@ -221,15 +109,11 @@ impl LlmConfig {
|
||||
failover_cooldown_threshold: 3,
|
||||
smart_routing_cascade: false,
|
||||
},
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a model name from env var → settings.selected_model → hardcoded default.
|
||||
/// Resolve a model name from env var -> settings.selected_model -> hardcoded default.
|
||||
fn resolve_model(
|
||||
env_var: &str,
|
||||
settings: &Settings,
|
||||
@@ -241,31 +125,40 @@ impl LlmConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
// Determine backend: env var > settings > default (NearAi)
|
||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "LLM_BACKEND".to_string(),
|
||||
message: e,
|
||||
})?
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
// Determine backend: env var > settings > default ("nearai")
|
||||
let backend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
b
|
||||
} else if let Some(ref b) = settings.llm_backend {
|
||||
match b.parse() {
|
||||
Ok(backend) => backend,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
|
||||
b,
|
||||
e
|
||||
);
|
||||
LlmBackend::NearAi
|
||||
}
|
||||
}
|
||||
b.clone()
|
||||
} else {
|
||||
LlmBackend::NearAi
|
||||
"nearai".to_string()
|
||||
};
|
||||
|
||||
// Resolve NEAR AI config only when backend is NearAi (or when explicitly configured)
|
||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
// Validate the backend is known
|
||||
let backend_lower = backend.to_lowercase();
|
||||
let is_nearai =
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
|
||||
if !is_nearai && registry.find(&backend_lower).is_none() {
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
backend
|
||||
);
|
||||
}
|
||||
|
||||
// Session config (used by NearAI provider for OAuth/session-token auth)
|
||||
let session = SessionConfig {
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
};
|
||||
|
||||
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
|
||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
let nearai = NearAiConfig {
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
@@ -276,11 +169,6 @@ impl LlmConfig {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
}),
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
api_key: nearai_api_key,
|
||||
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
|
||||
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
|
||||
@@ -300,107 +188,155 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve provider-specific configs based on backend
|
||||
let openai = if backend == LlmBackend::OpenAi {
|
||||
let api_key = optional_env("OPENAI_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "OPENAI_API_KEY".to_string(),
|
||||
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
||||
})?;
|
||||
let model = Self::resolve_model("OPENAI_MODEL", settings, "gpt-4o")?;
|
||||
let base_url = optional_env("OPENAI_BASE_URL")?;
|
||||
Some(OpenAiDirectConfig {
|
||||
api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
} else {
|
||||
// Resolve registry provider config (for non-NearAI backends)
|
||||
let provider = if is_nearai {
|
||||
None
|
||||
};
|
||||
|
||||
let anthropic = if backend == LlmBackend::Anthropic {
|
||||
let api_key = optional_env("ANTHROPIC_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
||||
})?;
|
||||
let model =
|
||||
Self::resolve_model("ANTHROPIC_MODEL", settings, "claude-sonnet-4-20250514")?;
|
||||
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
|
||||
Some(AnthropicDirectConfig {
|
||||
api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let ollama = if backend == LlmBackend::Ollama {
|
||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
let model = Self::resolve_model("OLLAMA_MODEL", settings, "llama3")?;
|
||||
Some(OllamaConfig { base_url, model })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||
let base_url = optional_env("LLM_BASE_URL")?
|
||||
.or_else(|| settings.openai_compatible_base_url.clone())
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "LLM_BASE_URL".to_string(),
|
||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||
})?;
|
||||
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
||||
let model = Self::resolve_model("LLM_MODEL", settings, "default")?;
|
||||
let extra_headers = optional_env("LLM_EXTRA_HEADERS")?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
Some(OpenAiCompatibleConfig {
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
extra_headers,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tinfoil = if backend == LlmBackend::Tinfoil {
|
||||
let api_key = optional_env("TINFOIL_API_KEY")?
|
||||
.map(SecretString::from)
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "TINFOIL_API_KEY".to_string(),
|
||||
hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(),
|
||||
})?;
|
||||
let model = Self::resolve_model("TINFOIL_MODEL", settings, "kimi-k2-5")?;
|
||||
Some(TinfoilConfig { api_key, model })
|
||||
} else {
|
||||
None
|
||||
Some(Self::resolve_registry_provider(
|
||||
&backend_lower,
|
||||
®istry,
|
||||
settings,
|
||||
)?)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
backend: if is_nearai {
|
||||
"nearai".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
p.provider_id.clone()
|
||||
} else {
|
||||
backend_lower
|
||||
},
|
||||
session,
|
||||
nearai,
|
||||
openai,
|
||||
anthropic,
|
||||
ollama,
|
||||
openai_compatible,
|
||||
tinfoil,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a `RegistryProviderConfig` from the registry and env vars.
|
||||
fn resolve_registry_provider(
|
||||
backend: &str,
|
||||
registry: &ProviderRegistry,
|
||||
settings: &Settings,
|
||||
) -> Result<RegistryProviderConfig, ConfigError> {
|
||||
// Look up provider definition. Fall back to openai_compatible if unknown.
|
||||
let def = registry
|
||||
.find(backend)
|
||||
.or_else(|| registry.find("openai_compatible"));
|
||||
|
||||
let (
|
||||
canonical_id,
|
||||
protocol,
|
||||
api_key_env,
|
||||
base_url_env,
|
||||
model_env,
|
||||
default_model,
|
||||
default_base_url,
|
||||
extra_headers_env,
|
||||
api_key_required,
|
||||
base_url_required,
|
||||
) = if let Some(def) = def {
|
||||
(
|
||||
def.id.as_str(),
|
||||
def.protocol,
|
||||
def.api_key_env.as_deref(),
|
||||
def.base_url_env.as_deref(),
|
||||
def.model_env.as_str(),
|
||||
def.default_model.as_str(),
|
||||
def.default_base_url.as_deref(),
|
||||
def.extra_headers_env.as_deref(),
|
||||
def.api_key_required,
|
||||
def.base_url_required,
|
||||
)
|
||||
} else {
|
||||
// Absolute fallback: treat as generic openai_completions
|
||||
(
|
||||
backend,
|
||||
ProviderProtocol::OpenAiCompletions,
|
||||
Some("LLM_API_KEY"),
|
||||
Some("LLM_BASE_URL"),
|
||||
"LLM_MODEL",
|
||||
"default",
|
||||
None,
|
||||
Some("LLM_EXTRA_HEADERS"),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
// Resolve API key from env
|
||||
let api_key = if let Some(env_var) = api_key_env {
|
||||
optional_env(env_var)?.map(SecretString::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if api_key_required && api_key.is_none() {
|
||||
// Don't hard-fail here. The key might be injected later from the secrets store
|
||||
// via inject_llm_keys_from_secrets(). Log a warning instead.
|
||||
if let Some(env_var) = api_key_env {
|
||||
tracing::debug!(
|
||||
"API key not found in {env_var} for backend '{backend}'. \
|
||||
Will be injected from secrets store if available."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve base URL: env var > settings (backward compat) > registry default
|
||||
let base_url = if let Some(env_var) = base_url_env {
|
||||
optional_env(env_var)?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
// Backward compat: check legacy settings fields
|
||||
match backend {
|
||||
"ollama" => settings.ollama_base_url.clone(),
|
||||
"openai_compatible" | "openrouter" => settings.openai_compatible_base_url.clone(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.or_else(|| default_base_url.map(String::from))
|
||||
.unwrap_or_default();
|
||||
|
||||
if base_url_required
|
||||
&& base_url.is_empty()
|
||||
&& let Some(env_var) = base_url_env
|
||||
{
|
||||
return Err(ConfigError::MissingRequired {
|
||||
key: env_var.to_string(),
|
||||
hint: format!("Set {env_var} when LLM_BACKEND={backend}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
let model = Self::resolve_model(model_env, settings, default_model)?;
|
||||
|
||||
// Resolve extra headers
|
||||
let extra_headers = if let Some(env_var) = extra_headers_env {
|
||||
optional_env(env_var)?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(RegistryProviderConfig {
|
||||
protocol,
|
||||
provider_id: canonical_id.to_string(),
|
||||
api_key,
|
||||
base_url,
|
||||
model,
|
||||
extra_headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs.
|
||||
///
|
||||
/// Format: `Key1:Value1,Key2:Value2` — colon-separated key:value, comma-separated pairs.
|
||||
/// Colon is used as the separator (not `=`) because header values often contain `=`
|
||||
/// (e.g., base64 tokens).
|
||||
/// Format: `Key1:Value1,Key2:Value2` (colon-separated, not `=`, because
|
||||
/// header values often contain `=`).
|
||||
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
|
||||
if val.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -464,11 +400,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
|
||||
assert_eq!(compat.model, "openai/gpt-5.1-codex");
|
||||
assert_eq!(provider.model, "openai/gpt-5.1-codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -488,11 +422,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
|
||||
assert_eq!(compat.model, "openai/gpt-5-codex");
|
||||
assert_eq!(provider.model, "openai/gpt-5-codex");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
@@ -538,7 +470,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extra_headers_value_with_colons() {
|
||||
// Values can contain colons (e.g., URLs)
|
||||
let result = parse_extra_headers("Authorization:Bearer abc:def").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
@@ -587,9 +518,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
|
||||
assert_eq!(ollama.model, "llama3.2");
|
||||
assert_eq!(provider.model, "llama3.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -608,9 +539,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
|
||||
assert_eq!(ollama.model, "mistral:latest");
|
||||
assert_eq!(provider.model, "mistral:latest");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
@@ -631,13 +562,197 @@ mod tests {
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
|
||||
assert_eq!(
|
||||
compat.model, "llama3.2",
|
||||
provider.model, "llama3.2",
|
||||
"model name with dot must not be truncated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_provider_resolves_groq() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
std::env::remove_var("GROQ_MODEL");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("groq".to_string()),
|
||||
selected_model: Some("llama-3.3-70b-versatile".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "groq");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.provider_id, "groq");
|
||||
assert_eq!(provider.model, "llama-3.3-70b-versatile");
|
||||
assert_eq!(provider.base_url, "https://api.groq.com/openai/v1");
|
||||
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_provider_resolves_tinfoil() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("TINFOIL_API_KEY");
|
||||
std::env::remove_var("TINFOIL_MODEL");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("tinfoil".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "tinfoil");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||
assert_eq!(provider.model, "kimi-k2-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearai_backend_has_no_registry_provider() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "nearai");
|
||||
assert!(cfg.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_alias_normalized_to_canonical_id() {
|
||||
// When the user sets LLM_BACKEND to an alias (e.g., "open_ai"),
|
||||
// LlmConfig.backend should resolve to the canonical ID ("openai").
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "open_ai");
|
||||
std::env::set_var("OPENAI_API_KEY", "test-key");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(
|
||||
cfg.backend, "openai",
|
||||
"alias 'open_ai' should be normalized to canonical 'openai'"
|
||||
);
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(provider.provider_id, "openai");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_backend_falls_back_to_openai_compatible() {
|
||||
// An unrecognized LLM_BACKEND should fall back to the openai_compatible
|
||||
// provider definition instead of erroring.
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "some_custom_provider");
|
||||
std::env::set_var("LLM_BASE_URL", "http://localhost:8080/v1");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
// Falls back to openai_compatible since "some_custom_provider" is unknown
|
||||
assert_eq!(cfg.backend, "openai_compatible");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(provider.provider_id, "openai_compatible");
|
||||
assert_eq!(provider.base_url, "http://localhost:8080/v1");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("LLM_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearai_aliases_all_resolve_to_nearai() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
|
||||
for alias in &["nearai", "near_ai", "near"] {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", alias);
|
||||
}
|
||||
let settings = Settings::default();
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(
|
||||
cfg.backend, "nearai",
|
||||
"alias '{alias}' should resolve to 'nearai'"
|
||||
);
|
||||
assert!(
|
||||
cfg.provider.is_none(),
|
||||
"nearai should not have a registry provider"
|
||||
);
|
||||
}
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_resolution_priority() {
|
||||
// Env var > settings > registry default
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "openai_compatible");
|
||||
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_compatible".to_string()),
|
||||
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://env-url/v1",
|
||||
"env var should take priority over settings"
|
||||
);
|
||||
|
||||
// Now without env var, settings should win over registry default
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BASE_URL");
|
||||
}
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://settings-url/v1",
|
||||
"settings should take priority over registry default"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-10
@@ -36,10 +36,7 @@ pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsq
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::{
|
||||
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
|
||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
||||
};
|
||||
pub use self::llm::{LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
@@ -47,6 +44,7 @@ pub use self::secrets::SecretsConfig;
|
||||
pub use self::skills::SkillsConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
|
||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||
///
|
||||
@@ -286,12 +284,29 @@ pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
user_id: &str,
|
||||
) {
|
||||
let mappings = [
|
||||
("llm_openai_api_key", "OPENAI_API_KEY"),
|
||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||
];
|
||||
// Static mappings for well-known providers.
|
||||
// The registry's setup hints define secret_name -> env_var mappings,
|
||||
// so new providers added to providers.json get injection automatically.
|
||||
let mut mappings: Vec<(&str, &str)> = vec![("llm_nearai_api_key", "NEARAI_API_KEY")];
|
||||
|
||||
// Dynamically discover secret->env mappings from the provider registry.
|
||||
// Uses selectable() which deduplicates user overrides correctly.
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let dynamic_mappings: Vec<(String, String)> = registry
|
||||
.selectable()
|
||||
.iter()
|
||||
.filter_map(|def| {
|
||||
def.api_key_env.as_ref().and_then(|env_var| {
|
||||
def.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.secret_name())
|
||||
.map(|secret_name| (secret_name.to_string(), env_var.clone()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (secret, env_var) in &dynamic_mappings {
|
||||
mappings.push((secret, env_var));
|
||||
}
|
||||
|
||||
let mut injected = HashMap::new();
|
||||
|
||||
|
||||
@@ -292,6 +292,8 @@ impl Database for LibSqlBackend {
|
||||
conn.execute_batch(libsql_migrations::SCHEMA)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
|
||||
// Apply incremental migrations (V9+) tracked in _migrations table.
|
||||
libsql_migrations::run_incremental(&conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+30
-20
@@ -561,7 +561,10 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
.join(",")
|
||||
);
|
||||
|
||||
let mut rows = conn
|
||||
// vector_top_k requires a libsql_vector_idx index. After the V9
|
||||
// migration the index is dropped (to support flexible embedding
|
||||
// dimensions), so this query may fail. Fall back to FTS-only.
|
||||
match conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT c.id, c.document_id, d.path, c.content
|
||||
@@ -573,27 +576,34 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
params![vector_json, pre_limit, user_id, agent_id_str.as_deref()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector query failed: {}", e),
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector row fetch failed: {}", e),
|
||||
})?
|
||||
{
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
Ok(mut rows) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) =
|
||||
rows.next()
|
||||
.await
|
||||
.map_err(|e| WorkspaceError::SearchFailed {
|
||||
reason: format!("Vector row fetch failed: {}", e),
|
||||
})?
|
||||
{
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Vector index query failed (expected after V9 migration), \
|
||||
falling back to FTS-only: {e}"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
results
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
+137
-5
@@ -2,6 +2,9 @@
|
||||
//!
|
||||
//! Consolidates all PostgreSQL migrations (V1-V8) into a single SQLite-compatible
|
||||
//! schema. Run once on database creation; idempotent via `IF NOT EXISTS`.
|
||||
//!
|
||||
//! Incremental migrations (V9+) are tracked in the `_migrations` table and run
|
||||
//! exactly once per database, in version order.
|
||||
|
||||
/// Consolidated schema for libSQL.
|
||||
///
|
||||
@@ -12,7 +15,7 @@
|
||||
/// - `BYTEA` -> `BLOB`
|
||||
/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
|
||||
/// - `TEXT[]` -> `TEXT` (JSON array)
|
||||
/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
|
||||
/// - `VECTOR` -> `BLOB` (raw little-endian F32 bytes, any dimension)
|
||||
/// - `TSVECTOR` -> FTS5 virtual table
|
||||
/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
|
||||
/// - PL/pgSQL functions -> SQLite triggers
|
||||
@@ -221,16 +224,16 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
|
||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding F32_BLOB(1536),
|
||||
embedding BLOB,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
|
||||
-- Vector index for semantic search (libSQL native)
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
|
||||
ON memory_chunks (libsql_vector_idx(embedding));
|
||||
-- No vector index: BLOB column accepts any embedding dimension.
|
||||
-- Vector search uses brute-force cosine distance (fast enough for
|
||||
-- personal assistant workspaces). Matches PostgreSQL after V9 migration.
|
||||
|
||||
-- FTS5 virtual table for full-text search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
|
||||
@@ -566,3 +569,132 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
|
||||
|
||||
"#;
|
||||
|
||||
/// Incremental migrations applied after the base schema.
|
||||
///
|
||||
/// Each entry is `(version, name, sql)`. Migrations are idempotent: the
|
||||
/// `_migrations` table tracks which versions have been applied.
|
||||
pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
"flexible_embedding_dimension",
|
||||
// Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type
|
||||
// constraint so any embedding dimension works. Existing embeddings
|
||||
// are preserved; users only need to re-embed if they change models.
|
||||
//
|
||||
// The vector index (libsql_vector_idx) requires a fixed-dimension
|
||||
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
|
||||
// brute-force cosine distance which is fast enough for personal
|
||||
// assistant workspaces. This matches PostgreSQL after its V9 migration.
|
||||
//
|
||||
// SQLite cannot ALTER COLUMN types, so we recreate the table.
|
||||
r#"
|
||||
-- Drop vector index (requires fixed F32_BLOB(N), incompatible with flexible dimensions)
|
||||
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
|
||||
|
||||
-- Drop FTS triggers that reference the old table
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_update;
|
||||
|
||||
-- Recreate table with flexible BLOB column (any embedding dimension)
|
||||
CREATE TABLE IF NOT EXISTS memory_chunks_new (
|
||||
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
-- Copy all existing data (embeddings preserved as-is)
|
||||
INSERT OR IGNORE INTO memory_chunks_new (_rowid, id, document_id, chunk_index, content, embedding, created_at)
|
||||
SELECT _rowid, id, document_id, chunk_index, content, embedding, created_at FROM memory_chunks;
|
||||
|
||||
-- Swap tables
|
||||
DROP TABLE memory_chunks;
|
||||
ALTER TABLE memory_chunks_new RENAME TO memory_chunks;
|
||||
|
||||
-- Recreate indexes (no vector index — see comment above)
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
|
||||
-- Recreate FTS triggers
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
"#,
|
||||
)];
|
||||
|
||||
/// Run incremental migrations that haven't been applied yet.
|
||||
///
|
||||
/// Each migration is wrapped in a transaction. On success the version is
|
||||
/// recorded in `_migrations` so it won't run again.
|
||||
pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::error::DatabaseError> {
|
||||
use crate::error::DatabaseError;
|
||||
|
||||
for &(version, name, sql) in INCREMENTAL_MIGRATIONS {
|
||||
// Check if already applied
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT 1 FROM _migrations WHERE version = ?1",
|
||||
libsql::params![version],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to check migration {version}: {e}"))
|
||||
})?;
|
||||
|
||||
if rows.next().await.ok().flatten().is_some() {
|
||||
continue; // Already applied
|
||||
}
|
||||
|
||||
tracing::info!(version, name, "libSQL: applying incremental migration");
|
||||
|
||||
// Wrap migration + recording in a transaction for atomicity.
|
||||
// If the process crashes mid-migration, the transaction rolls back
|
||||
// and the migration will be retried on next startup.
|
||||
let tx = conn.transaction().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"libSQL migration V{version}: failed to start transaction: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tx.execute_batch(sql).await.map_err(|e| {
|
||||
DatabaseError::Migration(format!("libSQL migration V{version} ({name}) failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Record as applied (inside the same transaction)
|
||||
tx.execute(
|
||||
"INSERT INTO _migrations (version, name) VALUES (?1, ?2)",
|
||||
libsql::params![version, name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"Failed to record migration V{version} ({name}): {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"libSQL migration V{version} ({name}): commit failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(version, name, "libSQL: migration applied successfully");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Detection of image generation models across inference providers.
|
||||
|
||||
/// Check if a model name indicates image generation capability.
|
||||
///
|
||||
/// Detects models like:
|
||||
/// - FLUX (Black Forest Labs): `flux`, `flux.2`, `flux-pro`, etc.
|
||||
/// - DALL-E (OpenAI): `dall-e-2`, `dall-e-3`, etc.
|
||||
/// - Stable Diffusion: `stable-diffusion`, `sdxl`, etc.
|
||||
/// - Imagen (Google): `imagen`, `imagen-2`, etc.
|
||||
/// - Other generation models
|
||||
pub fn is_image_generation_model(model: &str) -> bool {
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
// FLUX models
|
||||
if model_lower.contains("flux") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DALL-E models
|
||||
if model_lower.contains("dall-e") || model_lower.contains("dalle") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stable Diffusion models
|
||||
if model_lower.contains("stable-diffusion")
|
||||
|| model_lower.contains("sdxl")
|
||||
|| model_lower.contains("stability")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Imagen models
|
||||
if model_lower.contains("imagen") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Midjourney (if exposed via API)
|
||||
if model_lower.contains("midjourney") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Replicate FLUX via API
|
||||
if model_lower.contains("black-forest-labs") || model_lower.contains("lucataco") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if any model in a list is an image generation model.
|
||||
pub fn has_image_generation_model(models: &[String]) -> bool {
|
||||
models.iter().any(|m| is_image_generation_model(m))
|
||||
}
|
||||
|
||||
/// Suggest the best image generation model from available models.
|
||||
///
|
||||
/// Priority: FLUX > DALL-E > others
|
||||
pub fn suggest_image_model(models: &[String]) -> Option<String> {
|
||||
// Prefer FLUX
|
||||
if let Some(flux) = models.iter().find(|m| m.to_lowercase().contains("flux")) {
|
||||
return Some(flux.clone());
|
||||
}
|
||||
|
||||
// Then DALL-E
|
||||
if let Some(dalle) = models
|
||||
.iter()
|
||||
.find(|m| m.to_lowercase().contains("dall-e") || m.to_lowercase().contains("dalle"))
|
||||
{
|
||||
return Some(dalle.clone());
|
||||
}
|
||||
|
||||
// Then any other image model
|
||||
models
|
||||
.iter()
|
||||
.find(|m| is_image_generation_model(m))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_flux_detection() {
|
||||
assert!(is_image_generation_model(
|
||||
"black-forest-labs/FLUX.2-klein-4B"
|
||||
));
|
||||
assert!(is_image_generation_model("flux"));
|
||||
assert!(is_image_generation_model("flux-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dalle_detection() {
|
||||
assert!(is_image_generation_model("dall-e-3"));
|
||||
assert!(is_image_generation_model("dall-e-2"));
|
||||
assert!(is_image_generation_model("dalle-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stable_diffusion_detection() {
|
||||
assert!(is_image_generation_model("stable-diffusion-3"));
|
||||
assert!(is_image_generation_model("sdxl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_imagen_detection() {
|
||||
assert!(is_image_generation_model("imagen"));
|
||||
assert!(is_image_generation_model("imagen-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_image_models() {
|
||||
assert!(!is_image_generation_model("claude-3-5-sonnet"));
|
||||
assert!(!is_image_generation_model("gpt-4"));
|
||||
assert!(!is_image_generation_model("gemini-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_image_model() {
|
||||
let models = vec![
|
||||
"gpt-4".to_string(),
|
||||
"black-forest-labs/FLUX.2-klein-4B".to_string(),
|
||||
"dall-e-3".to_string(),
|
||||
];
|
||||
|
||||
// Should prefer FLUX
|
||||
assert_eq!(
|
||||
suggest_image_model(&models),
|
||||
Some("black-forest-labs/FLUX.2-klein-4B".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_dalle_when_no_flux() {
|
||||
let models = vec!["gpt-4".to_string(), "dall-e-3".to_string()];
|
||||
|
||||
assert_eq!(suggest_image_model(&models), Some("dall-e-3".to_string()));
|
||||
}
|
||||
}
|
||||
+147
-178
@@ -10,28 +10,33 @@
|
||||
pub mod circuit_breaker;
|
||||
pub mod costs;
|
||||
pub mod failover;
|
||||
pub mod image_models;
|
||||
mod nearai_chat;
|
||||
mod provider;
|
||||
mod reasoning;
|
||||
pub mod recording;
|
||||
pub mod registry;
|
||||
pub mod response_cache;
|
||||
pub mod retry;
|
||||
mod rig_adapter;
|
||||
pub mod session;
|
||||
pub mod smart_routing;
|
||||
pub mod vision_models;
|
||||
|
||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
||||
pub use provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
||||
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, ImageAttachment, LlmProvider,
|
||||
ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition,
|
||||
ToolResult,
|
||||
};
|
||||
pub use reasoning::{
|
||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
||||
TokenUsage, ToolSelection, is_silent_reply,
|
||||
};
|
||||
pub use recording::RecordingLlm;
|
||||
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
||||
pub use response_cache::{CachedProvider, ResponseCacheConfig};
|
||||
pub use retry::{RetryConfig, RetryProvider};
|
||||
pub use rig_adapter::RigAdapter;
|
||||
@@ -43,26 +48,29 @@ use std::sync::Arc;
|
||||
use rig::client::CompletionClient;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
|
||||
use crate::config::{LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
use crate::error::LlmError;
|
||||
|
||||
/// Create an LLM provider based on configuration.
|
||||
///
|
||||
/// - `NearAi` backend: Uses session manager for authentication (Responses API)
|
||||
/// or API key (Chat Completions API)
|
||||
/// - Other backends: Use rig-core adapter with provider-specific clients
|
||||
/// - NearAI backend: Uses session manager for authentication
|
||||
/// - Registry providers: Looked up by protocol and constructed generically
|
||||
pub fn create_llm_provider(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
match config.backend {
|
||||
LlmBackend::NearAi => create_llm_provider_with_config(&config.nearai, session),
|
||||
LlmBackend::OpenAi => create_openai_provider(config),
|
||||
LlmBackend::Anthropic => create_anthropic_provider(config),
|
||||
LlmBackend::Ollama => create_ollama_provider(config),
|
||||
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
||||
LlmBackend::Tinfoil => create_tinfoil_provider(config),
|
||||
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
|
||||
return create_llm_provider_with_config(&config.nearai, session);
|
||||
}
|
||||
|
||||
let reg_config = config
|
||||
.provider
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: config.backend.clone(),
|
||||
})?;
|
||||
|
||||
create_registry_provider(reg_config)
|
||||
}
|
||||
|
||||
/// Create an LLM provider from a `NearAiConfig` directly.
|
||||
@@ -87,184 +95,151 @@ pub fn create_llm_provider_with_config(
|
||||
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
|
||||
}
|
||||
|
||||
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
// Use CompletionsClient (Chat Completions API) instead of the default Client
|
||||
// (Responses API). The Responses API path in rig-core panics when tool results
|
||||
// are sent back because ironclaw doesn't thread `call_id` through its ToolCall
|
||||
// type. The Chat Completions API works correctly with the existing code.
|
||||
let client: openai::CompletionsClient = if let Some(ref base_url) = oai.base_url {
|
||||
tracing::info!(
|
||||
"Using OpenAI direct API (chat completions, model: {}, base_url: {})",
|
||||
oai.model,
|
||||
base_url,
|
||||
);
|
||||
openai::Client::builder()
|
||||
.base_url(base_url)
|
||||
.api_key(oai.api_key.expose_secret())
|
||||
.build()
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Using OpenAI direct API (chat completions, model: {}, base_url: default)",
|
||||
oai.model,
|
||||
);
|
||||
openai::Client::new(oai.api_key.expose_secret())
|
||||
/// Create a provider from a registry-resolved config.
|
||||
///
|
||||
/// Dispatches on `RegistryProviderConfig::protocol` to build the appropriate
|
||||
/// rig-core client. This single function replaces what used to be 5 separate
|
||||
/// `create_*_provider` functions.
|
||||
fn create_registry_provider(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
match config.protocol {
|
||||
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
||||
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
||||
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai".to_string(),
|
||||
reason: format!("Failed to create OpenAI client: {}", e),
|
||||
})?
|
||||
.completions_api();
|
||||
|
||||
let model = client.completion_model(&oai.model);
|
||||
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
|
||||
}
|
||||
|
||||
fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let anth = config
|
||||
.anthropic
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "anthropic".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::anthropic;
|
||||
|
||||
let client: anthropic::Client = if let Some(ref base_url) = anth.base_url {
|
||||
anthropic::Client::builder()
|
||||
.api_key(anth.api_key.expose_secret())
|
||||
.base_url(base_url)
|
||||
.build()
|
||||
} else {
|
||||
anthropic::Client::new(anth.api_key.expose_secret())
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "anthropic".to_string(),
|
||||
reason: format!("Failed to create Anthropic client: {}", e),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&anth.model);
|
||||
tracing::info!(
|
||||
"Using Anthropic direct API (model: {}, base_url: {})",
|
||||
anth.model,
|
||||
anth.base_url.as_deref().unwrap_or("default"),
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
|
||||
}
|
||||
|
||||
fn create_ollama_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "ollama".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::client::Nothing;
|
||||
use rig::providers::ollama;
|
||||
|
||||
let client: ollama::Client = ollama::Client::builder()
|
||||
.base_url(&oll.base_url)
|
||||
.api_key(Nothing)
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "ollama".to_string(),
|
||||
reason: format!("Failed to create Ollama client: {}", e),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&oll.model);
|
||||
tracing::info!(
|
||||
"Using Ollama (base_url: {}, model: {})",
|
||||
oll.base_url,
|
||||
oll.model
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &oll.model)))
|
||||
}
|
||||
|
||||
const TINFOIL_BASE_URL: &str = "https://inference.tinfoil.sh/v1";
|
||||
|
||||
fn create_tinfoil_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let tf = config
|
||||
.tinfoil
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "tinfoil".to_string(),
|
||||
})?;
|
||||
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.base_url(TINFOIL_BASE_URL)
|
||||
.api_key(tf.api_key.expose_secret())
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "tinfoil".to_string(),
|
||||
reason: format!("Failed to create Tinfoil client: {}", e),
|
||||
})?;
|
||||
|
||||
// Tinfoil currently only supports the Chat Completions API and not the newer Responses API,
|
||||
// so we must explicitly select the completions API here (unlike other OpenAI-compatible providers).
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model(&tf.model);
|
||||
tracing::info!("Using Tinfoil private inference (model: {})", tf.model);
|
||||
Ok(Arc::new(RigAdapter::new(model, &tf.model)))
|
||||
}
|
||||
|
||||
fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let compat = config
|
||||
.openai_compatible
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_compatible".to_string(),
|
||||
})?;
|
||||
|
||||
fn create_openai_compat_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
use rig::providers::openai;
|
||||
|
||||
let mut extra_headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in &compat.extra_headers {
|
||||
for (key, value) in &config.extra_headers {
|
||||
let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header name");
|
||||
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let val = match reqwest::header::HeaderValue::from_str(value) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header value");
|
||||
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
extra_headers.insert(name, val);
|
||||
}
|
||||
|
||||
let client: openai::CompletionsClient = openai::Client::builder()
|
||||
.base_url(&compat.base_url)
|
||||
.api_key(
|
||||
compat
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.unwrap_or_else(|| "no-key".to_string()),
|
||||
)
|
||||
.http_headers(extra_headers)
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = %config.provider_id,
|
||||
"No API key configured for {}. Requests will likely fail with 401. \
|
||||
Check your .env or secrets store.",
|
||||
config.provider_id,
|
||||
);
|
||||
"no-key".to_string()
|
||||
});
|
||||
|
||||
let mut builder = openai::Client::builder().api_key(&api_key);
|
||||
if !config.base_url.is_empty() {
|
||||
builder = builder.base_url(&config.base_url);
|
||||
}
|
||||
if !extra_headers.is_empty() {
|
||||
builder = builder.http_headers(extra_headers);
|
||||
}
|
||||
|
||||
let client: openai::Client = builder.build().map_err(|e| LlmError::RequestFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create OpenAI-compatible client: {e}"),
|
||||
})?;
|
||||
|
||||
// Use CompletionsClient (Chat Completions API) instead of the default
|
||||
// Client (Responses API). The Responses API path in rig-core handles
|
||||
// tool results differently, which breaks IronClaw's tool call flow.
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
tracing::info!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
"Using OpenAI-compatible provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
}
|
||||
|
||||
fn create_anthropic_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
use rig::providers::anthropic;
|
||||
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
})?;
|
||||
|
||||
let client: anthropic::Client = if config.base_url.is_empty() {
|
||||
anthropic::Client::new(&api_key)
|
||||
} else {
|
||||
anthropic::Client::builder()
|
||||
.api_key(&api_key)
|
||||
.base_url(&config.base_url)
|
||||
.build()
|
||||
}
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create Anthropic client: {e}"),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
tracing::info!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
||||
"Using Anthropic provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
}
|
||||
|
||||
fn create_ollama_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
use rig::client::Nothing;
|
||||
use rig::providers::ollama;
|
||||
|
||||
let client: ollama::Client = ollama::Client::builder()
|
||||
.base_url(&config.base_url)
|
||||
.api_key(Nothing)
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai_compatible".to_string(),
|
||||
reason: format!("Failed to create OpenAI-compatible client: {}", e),
|
||||
})?
|
||||
.completions_api();
|
||||
provider: config.provider_id.clone(),
|
||||
reason: format!("Failed to create Ollama client: {e}"),
|
||||
})?;
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
let model = client.completion_model(&compat.model);
|
||||
tracing::info!(
|
||||
"Using OpenAI-compatible endpoint (chat completions, base_url: {}, model: {})",
|
||||
compat.base_url,
|
||||
compat.model
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
"Using Ollama provider"
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
@@ -279,9 +254,9 @@ pub fn create_cheap_llm_provider(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if config.backend != LlmBackend::NearAi {
|
||||
if config.backend != "nearai" {
|
||||
tracing::warn!(
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is '{}', not nearai. \
|
||||
Cheap model setting will be ignored.",
|
||||
config.backend
|
||||
);
|
||||
@@ -456,16 +431,13 @@ pub fn build_provider_chain(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LlmBackend, NearAiConfig};
|
||||
use std::path::PathBuf;
|
||||
use crate::config::NearAiConfig;
|
||||
|
||||
fn test_nearai_config() -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "https://api.near.ai".to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: PathBuf::from("/tmp/test-session.json"),
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
@@ -482,13 +454,10 @@ mod tests {
|
||||
|
||||
fn test_llm_config() -> LlmConfig {
|
||||
LlmConfig {
|
||||
backend: LlmBackend::NearAi,
|
||||
backend: "nearai".to_string(),
|
||||
session: SessionConfig::default(),
|
||||
nearai: test_nearai_config(),
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
provider: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +488,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
|
||||
let mut config = test_llm_config();
|
||||
config.backend = LlmBackend::OpenAi;
|
||||
config.backend = "openai".to_string();
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
|
||||
+198
-39
@@ -138,13 +138,45 @@ impl NearAiChatProvider {
|
||||
}
|
||||
|
||||
/// Resolve the Bearer token for the current auth mode.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. `config.api_key` (set at construction from env/config)
|
||||
/// 2. Session token (OAuth flow)
|
||||
/// 3. `NEARAI_API_KEY` env var (set by interactive `api_key_login()`)
|
||||
///
|
||||
/// The env var fallback (#3) only triggers after `ensure_authenticated()`
|
||||
/// runs, because `api_key_login()` sets the env var but not a session token.
|
||||
async fn resolve_bearer_token(&self) -> Result<String, LlmError> {
|
||||
// 1. Config-level API key takes priority
|
||||
if let Some(ref api_key) = self.config.api_key {
|
||||
Ok(api_key.expose_secret().to_string())
|
||||
} else {
|
||||
let token = self.session.get_token().await?;
|
||||
Ok(token.expose_secret().to_string())
|
||||
return Ok(api_key.expose_secret().to_string());
|
||||
}
|
||||
|
||||
// 2. Existing session token (OAuth was already completed)
|
||||
if self.session.has_token().await {
|
||||
let token = self.session.get_token().await?;
|
||||
return Ok(token.expose_secret().to_string());
|
||||
}
|
||||
|
||||
// No token yet, trigger interactive login
|
||||
self.session.ensure_authenticated().await?;
|
||||
|
||||
// 3. After login, check if a session token was stored (OAuth path)
|
||||
if self.session.has_token().await {
|
||||
let token = self.session.get_token().await?;
|
||||
return Ok(token.expose_secret().to_string());
|
||||
}
|
||||
|
||||
// 4. api_key_login() sets NEARAI_API_KEY env var but not a session token
|
||||
if let Ok(key) = std::env::var("NEARAI_API_KEY")
|
||||
&& !key.is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
Err(LlmError::AuthFailed {
|
||||
provider: "nearai".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a single request to the chat completions API.
|
||||
@@ -639,7 +671,7 @@ struct ChatCompletionRequest {
|
||||
struct ChatCompletionMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
content: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -807,10 +839,15 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
|
||||
// Convert assistant tool_calls into descriptive text
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(ref text) = msg.content
|
||||
&& !text.is_empty()
|
||||
{
|
||||
parts.push(text.clone());
|
||||
if let Some(content) = &msg.content {
|
||||
// Extract string from JSON value
|
||||
let text = match content {
|
||||
serde_json::Value::String(s) => s.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
if !text.is_empty() {
|
||||
parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
for tc in calls {
|
||||
parts.push(format!(
|
||||
@@ -820,7 +857,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
}
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(parts.join("\n")),
|
||||
content: Some(serde_json::json!(parts.join("\n"))),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
@@ -829,10 +866,16 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
} else if msg.role == "tool" {
|
||||
// Convert tool result into a user message
|
||||
let tool_name = msg.name.as_deref().unwrap_or("unknown");
|
||||
let result = msg.content.as_deref().unwrap_or("");
|
||||
let result = match &msg.content {
|
||||
Some(serde_json::Value::String(s)) => s.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
|
||||
content: Some(serde_json::json!(format!(
|
||||
"[Tool `{}` returned: {}]",
|
||||
tool_name, result
|
||||
))),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
@@ -870,8 +913,23 @@ impl From<ChatMessage> for ChatCompletionMessage {
|
||||
|
||||
let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() {
|
||||
None
|
||||
} else if !msg.images.is_empty() && role == "user" {
|
||||
// User message with images: create a content array with text and image parts
|
||||
let mut parts = vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": msg.content
|
||||
})];
|
||||
for img in msg.images {
|
||||
parts.push(serde_json::json!({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": format!("data:{};base64,{}", img.media_type, img.data)
|
||||
}
|
||||
}));
|
||||
}
|
||||
Some(serde_json::Value::Array(parts))
|
||||
} else {
|
||||
Some(msg.content)
|
||||
Some(serde_json::json!(msg.content))
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -983,8 +1041,6 @@ mod tests {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: std::path::PathBuf::from("/tmp/session.json"),
|
||||
api_key: Some(secrecy::SecretString::from("test-key".to_string())),
|
||||
cheap_model: None,
|
||||
fallback_model: None,
|
||||
@@ -1038,7 +1094,7 @@ mod tests {
|
||||
let msg = ChatMessage::user("Hello");
|
||||
let chat_msg: ChatCompletionMessage = msg.into();
|
||||
assert_eq!(chat_msg.role, "user");
|
||||
assert_eq!(chat_msg.content, Some("Hello".to_string()));
|
||||
assert_eq!(chat_msg.content, Some(serde_json::json!("Hello")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1112,14 +1168,14 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some("You are helpful.".to_string()),
|
||||
content: Some(serde_json::json!("You are helpful.")),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some("Hello".to_string()),
|
||||
content: Some(serde_json::json!("Hello")),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -1136,7 +1192,7 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some("test".to_string()),
|
||||
content: Some(serde_json::json!("test")),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -1157,7 +1213,7 @@ mod tests {
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some("hi".to_string()),
|
||||
content: Some(serde_json::json!("hi")),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("echo".to_string()),
|
||||
tool_calls: None,
|
||||
@@ -1170,24 +1226,28 @@ mod tests {
|
||||
// Assistant tool_calls → plain assistant text
|
||||
assert_eq!(result[1].role, "assistant");
|
||||
assert!(result[1].tool_calls.is_none());
|
||||
assert!(
|
||||
result[1]
|
||||
.content
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("[Called tool `echo`")
|
||||
);
|
||||
if let Some(content) = &result[1].content {
|
||||
if let serde_json::Value::String(s) = content {
|
||||
assert!(s.contains("[Called tool `echo`"));
|
||||
} else {
|
||||
panic!("Content should be a string");
|
||||
}
|
||||
} else {
|
||||
panic!("Content should be present");
|
||||
}
|
||||
|
||||
// Tool result → user message
|
||||
assert_eq!(result[2].role, "user");
|
||||
assert!(result[2].tool_call_id.is_none());
|
||||
assert!(
|
||||
result[2]
|
||||
.content
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("[Tool `echo` returned: hi]")
|
||||
);
|
||||
if let Some(content) = &result[2].content {
|
||||
if let serde_json::Value::String(s) = content {
|
||||
assert!(s.contains("[Tool `echo` returned: hi]"));
|
||||
} else {
|
||||
panic!("Content should be a string");
|
||||
}
|
||||
} else {
|
||||
panic!("Content should be present");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1195,7 +1255,7 @@ mod tests {
|
||||
let messages = vec![
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some("Let me check that.".to_string()),
|
||||
content: Some(serde_json::json!("Let me check that.")),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![ChatCompletionToolCall {
|
||||
@@ -1209,7 +1269,7 @@ mod tests {
|
||||
},
|
||||
ChatCompletionMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some("found it".to_string()),
|
||||
content: Some(serde_json::json!("found it")),
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
@@ -1217,9 +1277,16 @@ mod tests {
|
||||
];
|
||||
|
||||
let result = flatten_tool_messages(messages);
|
||||
let text = result[0].content.as_ref().unwrap();
|
||||
assert!(text.starts_with("Let me check that."));
|
||||
assert!(text.contains("[Called tool `search`"));
|
||||
if let Some(content) = result[0].content.as_ref() {
|
||||
if let serde_json::Value::String(text) = content {
|
||||
assert!(text.starts_with("Let me check that."));
|
||||
assert!(text.contains("[Called tool `search`"));
|
||||
} else {
|
||||
panic!("Content should be a string");
|
||||
}
|
||||
} else {
|
||||
panic!("Content should be present");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1399,4 +1466,96 @@ mod tests {
|
||||
);
|
||||
assert!(tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_config_api_key() {
|
||||
// When config.api_key is set, it takes top priority.
|
||||
let cfg = test_nearai_config("http://localhost:8318");
|
||||
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(token, "test-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_session_token() {
|
||||
// When config.api_key is None but session has a token, use session token.
|
||||
let mut cfg = test_nearai_config("http://localhost:8318");
|
||||
cfg.api_key = None;
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("session-tok-123".to_string()))
|
||||
.await;
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(token, "session-tok-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_session_beats_env_var() {
|
||||
// Session token takes priority over NEARAI_API_KEY env var.
|
||||
// This prevents unexpected auth mode switches mid-run.
|
||||
let mut cfg = test_nearai_config("http://localhost:8318");
|
||||
cfg.api_key = None;
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("oauth-token".to_string()))
|
||||
.await;
|
||||
|
||||
// Set env var that should NOT be used when session token exists
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::set_var("NEARAI_API_KEY", "env-api-key-should-not-win");
|
||||
}
|
||||
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(
|
||||
token, "oauth-token",
|
||||
"session token must take priority over env var"
|
||||
);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::remove_var("NEARAI_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_bearer_token_config_beats_session_and_env() {
|
||||
// Config API key should win even when session token AND env var are set.
|
||||
let cfg = test_nearai_config("http://localhost:8318");
|
||||
let session = test_session();
|
||||
session
|
||||
.set_token(secrecy::SecretString::from("session-tok".to_string()))
|
||||
.await;
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::set_var("NEARAI_API_KEY", "env-key");
|
||||
}
|
||||
|
||||
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
|
||||
let token = provider
|
||||
.resolve_bearer_token()
|
||||
.await
|
||||
.expect("should resolve");
|
||||
assert_eq!(
|
||||
token, "test-key",
|
||||
"config api_key must win over session token and env var"
|
||||
);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
unsafe {
|
||||
std::env::remove_var("NEARAI_API_KEY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ pub enum Role {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// An image attachment for user messages.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageAttachment {
|
||||
/// MIME type (e.g., "image/jpeg", "image/png", "image/gif", "image/webp")
|
||||
pub media_type: String,
|
||||
/// Base64-encoded image data (without data URL prefix)
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// A message in a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
@@ -31,6 +40,9 @@ pub struct ChatMessage {
|
||||
/// to appear on the assistant message preceding tool result messages).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// Images attached to user messages.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub images: Vec<ImageAttachment>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
@@ -42,6 +54,7 @@ impl ChatMessage {
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +66,19 @@ impl ChatMessage {
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message with image attachments.
|
||||
pub fn user_with_images(content: impl Into<String>, images: Vec<ImageAttachment>) -> Self {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: content.into(),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +90,7 @@ impl ChatMessage {
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +109,7 @@ impl ChatMessage {
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +125,7 @@ impl ChatMessage {
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
name: Some(name.into()),
|
||||
tool_calls: None,
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
//! Declarative LLM provider registry.
|
||||
//!
|
||||
//! Providers are defined in JSON (compiled-in defaults + optional user file)
|
||||
//! so adding a new OpenAI-compatible provider requires zero Rust code changes.
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────┐ ┌──────────────────────────┐
|
||||
//! │ providers.json │ │ ~/.ironclaw/providers.json│
|
||||
//! │ (built-in, embed) │ │ (user overrides/extras) │
|
||||
//! └────────┬────────────┘ └────────────┬─────────────┘
|
||||
//! │ │
|
||||
//! └──────────┬───────────────────┘
|
||||
//! ▼
|
||||
//! ┌──────────────────┐
|
||||
//! │ ProviderRegistry │
|
||||
//! │ .find("groq") │──▶ ProviderDefinition
|
||||
//! │ .all() │ ├ protocol
|
||||
//! │ .selectable() │ ├ default_base_url
|
||||
//! └──────────────────┘ ├ api_key_env
|
||||
//! └ ...
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// API protocol a provider speaks.
|
||||
///
|
||||
/// Determines which rig-core client constructor to use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderProtocol {
|
||||
/// OpenAI Chat Completions API (`/v1/chat/completions`).
|
||||
/// Used by: OpenAI, Tinfoil, Groq, NVIDIA NIM, OpenRouter, etc.
|
||||
OpenAiCompletions,
|
||||
/// Anthropic Messages API.
|
||||
Anthropic,
|
||||
/// Ollama API (OpenAI-ish, no API key required).
|
||||
Ollama,
|
||||
}
|
||||
|
||||
/// How the setup wizard should collect credentials for this provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum SetupHint {
|
||||
/// Collect an API key and store it in the encrypted secrets store.
|
||||
ApiKey {
|
||||
/// Key name in the secrets store (e.g., "llm_groq_api_key").
|
||||
secret_name: String,
|
||||
/// URL where the user can generate an API key.
|
||||
#[serde(default)]
|
||||
key_url: Option<String>,
|
||||
/// Human-readable name for display in the wizard.
|
||||
display_name: String,
|
||||
/// Whether this provider supports `/v1/models` listing.
|
||||
#[serde(default)]
|
||||
can_list_models: bool,
|
||||
/// Optional filter for model listing (e.g., "chat").
|
||||
#[serde(default)]
|
||||
models_filter: Option<String>,
|
||||
},
|
||||
/// Ollama-style setup: just a base URL, no API key.
|
||||
Ollama {
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
can_list_models: bool,
|
||||
},
|
||||
/// Generic OpenAI-compatible: ask for base URL + optional API key.
|
||||
OpenAiCompatible {
|
||||
secret_name: String,
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
can_list_models: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl SetupHint {
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::ApiKey { display_name, .. } => display_name,
|
||||
Self::Ollama { display_name, .. } => display_name,
|
||||
Self::OpenAiCompatible { display_name, .. } => display_name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_list_models(&self) -> bool {
|
||||
match self {
|
||||
Self::ApiKey {
|
||||
can_list_models, ..
|
||||
} => *can_list_models,
|
||||
Self::Ollama {
|
||||
can_list_models, ..
|
||||
} => *can_list_models,
|
||||
Self::OpenAiCompatible {
|
||||
can_list_models, ..
|
||||
} => *can_list_models,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey { secret_name, .. } => Some(secret_name),
|
||||
Self::OpenAiCompatible { secret_name, .. } => Some(secret_name),
|
||||
Self::Ollama { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn models_filter(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey { models_filter, .. } => models_filter.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Declarative definition of an LLM provider.
|
||||
///
|
||||
/// One JSON object in `providers.json` maps to one `ProviderDefinition`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderDefinition {
|
||||
/// Unique identifier used in `LLM_BACKEND` (e.g., "groq", "tinfoil").
|
||||
pub id: String,
|
||||
/// Alternative names accepted in `LLM_BACKEND` (e.g., ["nvidia_nim", "nim"]).
|
||||
#[serde(default)]
|
||||
pub aliases: Vec<String>,
|
||||
/// Which API protocol to use.
|
||||
pub protocol: ProviderProtocol,
|
||||
/// Default base URL. `None` means use the rig-core default for the protocol.
|
||||
#[serde(default)]
|
||||
pub default_base_url: Option<String>,
|
||||
/// Env var for base URL override (e.g., "OPENAI_BASE_URL").
|
||||
#[serde(default)]
|
||||
pub base_url_env: Option<String>,
|
||||
/// Whether a base URL is required (for generic openai_compatible).
|
||||
#[serde(default)]
|
||||
pub base_url_required: bool,
|
||||
/// Env var for the API key (e.g., "GROQ_API_KEY").
|
||||
#[serde(default)]
|
||||
pub api_key_env: Option<String>,
|
||||
/// Whether an API key is required to use this provider.
|
||||
#[serde(default)]
|
||||
pub api_key_required: bool,
|
||||
/// Env var for the model name (e.g., "GROQ_MODEL").
|
||||
pub model_env: String,
|
||||
/// Default model if none specified.
|
||||
pub default_model: String,
|
||||
/// Human-readable one-line description.
|
||||
pub description: String,
|
||||
/// Env var for extra HTTP headers (format: `Key:Value,Key2:Value2`).
|
||||
#[serde(default)]
|
||||
pub extra_headers_env: Option<String>,
|
||||
/// Setup wizard hints.
|
||||
#[serde(default)]
|
||||
pub setup: Option<SetupHint>,
|
||||
}
|
||||
|
||||
/// Registry of known LLM providers.
|
||||
///
|
||||
/// Built from compiled-in `providers.json` plus optional user overrides
|
||||
/// from `~/.ironclaw/providers.json`.
|
||||
pub struct ProviderRegistry {
|
||||
providers: Vec<ProviderDefinition>,
|
||||
/// Lowercase id/alias → index into `providers`.
|
||||
lookup: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
/// Build a registry from a list of provider definitions.
|
||||
///
|
||||
/// Later entries with duplicate IDs/aliases override earlier ones.
|
||||
pub fn new(providers: Vec<ProviderDefinition>) -> Self {
|
||||
let mut lookup = HashMap::new();
|
||||
for (idx, def) in providers.iter().enumerate() {
|
||||
lookup.insert(def.id.to_lowercase(), idx);
|
||||
for alias in &def.aliases {
|
||||
lookup.insert(alias.to_lowercase(), idx);
|
||||
}
|
||||
}
|
||||
Self { providers, lookup }
|
||||
}
|
||||
|
||||
/// Load the default registry: built-in providers + user overrides.
|
||||
///
|
||||
/// User providers from `~/.ironclaw/providers.json` are appended,
|
||||
/// with later entries overriding earlier ones by ID/alias.
|
||||
pub fn load() -> Self {
|
||||
let builtins: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json"))
|
||||
.expect("built-in providers.json must be valid JSON");
|
||||
|
||||
let mut all = builtins;
|
||||
|
||||
if let Some(user_path) = user_providers_path()
|
||||
&& user_path.exists()
|
||||
{
|
||||
match std::fs::read_to_string(&user_path) {
|
||||
Ok(contents) => match serde_json::from_str::<Vec<ProviderDefinition>>(&contents) {
|
||||
Ok(user_defs) => {
|
||||
tracing::info!(
|
||||
count = user_defs.len(),
|
||||
path = %user_path.display(),
|
||||
"Loaded user provider definitions"
|
||||
);
|
||||
all.extend(user_defs);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %user_path.display(),
|
||||
error = %e,
|
||||
"Failed to parse user providers.json, skipping"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %user_path.display(),
|
||||
error = %e,
|
||||
"Failed to read user providers.json, skipping"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::new(all)
|
||||
}
|
||||
|
||||
/// Look up a provider by ID or alias (case-insensitive).
|
||||
pub fn find(&self, id: &str) -> Option<&ProviderDefinition> {
|
||||
self.lookup
|
||||
.get(&id.to_lowercase())
|
||||
.map(|&idx| &self.providers[idx])
|
||||
}
|
||||
|
||||
/// All registered providers (built-in + user).
|
||||
pub fn all(&self) -> &[ProviderDefinition] {
|
||||
&self.providers
|
||||
}
|
||||
|
||||
/// Providers that should appear in the setup wizard's selection menu.
|
||||
///
|
||||
/// Returns all providers that have a `setup` hint, in registry order.
|
||||
/// NearAI is not in the registry (handled specially) so it won't appear here.
|
||||
pub fn selectable(&self) -> Vec<&ProviderDefinition> {
|
||||
// Deduplicate: only keep the last definition for each ID
|
||||
let mut seen = HashMap::new();
|
||||
for def in &self.providers {
|
||||
seen.insert(def.id.as_str(), def);
|
||||
}
|
||||
// Preserve order of first appearance, but use the last (overridden)
|
||||
// definition for each ID. A user override that adds `setup` to a
|
||||
// provider that previously lacked it will be included correctly.
|
||||
let mut result = Vec::new();
|
||||
let mut emitted = std::collections::HashSet::new();
|
||||
for def in &self.providers {
|
||||
if emitted.insert(def.id.as_str()) {
|
||||
let final_def = seen[def.id.as_str()];
|
||||
if final_def.setup.is_some() {
|
||||
result.push(final_def);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Check whether a backend string is a known provider (NearAI or registry).
|
||||
pub fn is_known(&self, backend: &str) -> bool {
|
||||
backend == "nearai"
|
||||
|| backend == "near_ai"
|
||||
|| backend == "near"
|
||||
|| self.find(backend).is_some()
|
||||
}
|
||||
|
||||
/// Get the model env var for a backend string.
|
||||
///
|
||||
/// Returns the registry provider's `model_env` if found,
|
||||
/// or `"NEARAI_MODEL"` for the NearAI backend.
|
||||
pub fn model_env_var(&self, backend: &str) -> &str {
|
||||
if backend == "nearai" || backend == "near_ai" || backend == "near" {
|
||||
return "NEARAI_MODEL";
|
||||
}
|
||||
self.find(backend)
|
||||
.map(|def| def.model_env.as_str())
|
||||
.unwrap_or("LLM_MODEL")
|
||||
}
|
||||
}
|
||||
|
||||
fn user_providers_path() -> Option<std::path::PathBuf> {
|
||||
Some(crate::bootstrap::ironclaw_base_dir().join("providers.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builtin_registry_loads() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert!(
|
||||
registry.all().len() >= 5,
|
||||
"should have at least 5 built-in providers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_id() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
let openai = registry.find("openai").expect("openai should exist");
|
||||
assert_eq!(openai.id, "openai");
|
||||
assert_eq!(openai.protocol, ProviderProtocol::OpenAiCompletions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_alias() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
let openai = registry
|
||||
.find("open_ai")
|
||||
.expect("alias open_ai should resolve");
|
||||
assert_eq!(openai.id, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_case_insensitive() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert!(registry.find("OpenAI").is_some());
|
||||
assert!(registry.find("GROQ").is_some());
|
||||
assert!(registry.find("Tinfoil").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_unknown_returns_none() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert!(registry.find("nonexistent_provider").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_has_setup_hints() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
let selectable = registry.selectable();
|
||||
assert!(!selectable.is_empty());
|
||||
for def in &selectable {
|
||||
assert!(
|
||||
def.setup.is_some(),
|
||||
"selectable provider {} must have setup hint",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_override_wins() {
|
||||
let builtins: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
let mut all = builtins;
|
||||
// Simulate user overriding tinfoil with a different default model
|
||||
all.push(ProviderDefinition {
|
||||
id: "tinfoil".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("https://custom.tinfoil.example/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: Some("TINFOIL_API_KEY".to_string()),
|
||||
api_key_required: true,
|
||||
model_env: "TINFOIL_MODEL".to_string(),
|
||||
default_model: "custom-model".to_string(),
|
||||
description: "Custom tinfoil".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
});
|
||||
let registry = ProviderRegistry::new(all);
|
||||
let tf = registry.find("tinfoil").expect("tinfoil should exist");
|
||||
assert_eq!(tf.default_model, "custom-model", "user override should win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_env_var_nearai() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert_eq!(registry.model_env_var("nearai"), "NEARAI_MODEL");
|
||||
assert_eq!(registry.model_env_var("near_ai"), "NEARAI_MODEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_env_var_registry_provider() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert_eq!(registry.model_env_var("groq"), "GROQ_MODEL");
|
||||
assert_eq!(registry.model_env_var("tinfoil"), "TINFOIL_MODEL");
|
||||
assert_eq!(registry.model_env_var("openai"), "OPENAI_MODEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_env_var_unknown_fallback() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert_eq!(registry.model_env_var("nonexistent"), "LLM_MODEL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_known() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
assert!(registry.is_known("nearai"));
|
||||
assert!(registry.is_known("openai"));
|
||||
assert!(registry.is_known("groq"));
|
||||
assert!(!registry.is_known("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_providers_have_required_fields() {
|
||||
let providers: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
for def in &providers {
|
||||
assert!(!def.id.is_empty(), "provider must have an id");
|
||||
assert!(!def.model_env.is_empty(), "{}: model_env required", def.id);
|
||||
assert!(
|
||||
!def.default_model.is_empty(),
|
||||
"{}: default_model required",
|
||||
def.id
|
||||
);
|
||||
assert!(
|
||||
!def.description.is_empty(),
|
||||
"{}: description required",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_compatible_providers_have_base_url() {
|
||||
let providers: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
for def in &providers {
|
||||
if def.protocol == ProviderProtocol::OpenAiCompletions
|
||||
&& def.id != "openai"
|
||||
&& def.id != "openai_compatible"
|
||||
{
|
||||
assert!(
|
||||
def.default_base_url.is_some(),
|
||||
"{}: OpenAI-completions provider should have a default_base_url",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_models_filter_accessor() {
|
||||
let registry = ProviderRegistry::new(
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap(),
|
||||
);
|
||||
// Groq has models_filter: "chat"
|
||||
let groq = registry.find("groq").expect("groq should exist");
|
||||
let filter = groq
|
||||
.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.models_filter())
|
||||
.expect("groq should have models_filter");
|
||||
assert_eq!(filter, "chat");
|
||||
|
||||
// OpenAI has no models_filter
|
||||
let openai = registry.find("openai").expect("openai should exist");
|
||||
assert!(
|
||||
openai
|
||||
.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.models_filter())
|
||||
.is_none(),
|
||||
"openai should not have models_filter"
|
||||
);
|
||||
|
||||
// Ollama setup hint variant should return None
|
||||
let ollama = registry.find("ollama").expect("ollama should exist");
|
||||
assert!(
|
||||
ollama
|
||||
.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.models_filter())
|
||||
.is_none(),
|
||||
"ollama should not have models_filter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_user_override_adds_setup() {
|
||||
// A built-in provider without setup hint should NOT appear in selectable().
|
||||
// But if a user override adds a setup hint, it SHOULD appear.
|
||||
let mut providers: Vec<ProviderDefinition> = vec![ProviderDefinition {
|
||||
id: "custom".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://localhost/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "CUSTOM_MODEL".to_string(),
|
||||
default_model: "m1".to_string(),
|
||||
description: "No setup".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None, // no setup hint
|
||||
}];
|
||||
|
||||
let registry = ProviderRegistry::new(providers.clone());
|
||||
assert!(
|
||||
registry.selectable().is_empty(),
|
||||
"provider without setup should not be selectable"
|
||||
);
|
||||
|
||||
// User override adds a setup hint
|
||||
providers.push(ProviderDefinition {
|
||||
id: "custom".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://localhost/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: Some("CUSTOM_API_KEY".to_string()),
|
||||
api_key_required: true,
|
||||
model_env: "CUSTOM_MODEL".to_string(),
|
||||
default_model: "m1".to_string(),
|
||||
description: "Now with setup".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::ApiKey {
|
||||
secret_name: "llm_custom_api_key".to_string(),
|
||||
key_url: None,
|
||||
display_name: "Custom".to_string(),
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
});
|
||||
|
||||
let registry = ProviderRegistry::new(providers);
|
||||
let selectable = registry.selectable();
|
||||
assert_eq!(
|
||||
selectable.len(),
|
||||
1,
|
||||
"user override with setup should appear"
|
||||
);
|
||||
assert_eq!(selectable[0].id, "custom");
|
||||
assert_eq!(
|
||||
selectable[0].description, "Now with setup",
|
||||
"should use the overridden definition"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_user_override_removes_setup() {
|
||||
// If a built-in has setup but user override removes it, it should
|
||||
// NOT appear in selectable().
|
||||
let providers = vec![
|
||||
ProviderDefinition {
|
||||
id: "provider_a".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://a/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: Some("A_KEY".to_string()),
|
||||
api_key_required: true,
|
||||
model_env: "A_MODEL".to_string(),
|
||||
default_model: "m1".to_string(),
|
||||
description: "Has setup".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::ApiKey {
|
||||
secret_name: "a".to_string(),
|
||||
key_url: None,
|
||||
display_name: "A".to_string(),
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
},
|
||||
// User override removes setup
|
||||
ProviderDefinition {
|
||||
id: "provider_a".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://a/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: Some("A_KEY".to_string()),
|
||||
api_key_required: false,
|
||||
model_env: "A_MODEL".to_string(),
|
||||
default_model: "m1".to_string(),
|
||||
description: "No setup now".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
},
|
||||
];
|
||||
|
||||
let registry = ProviderRegistry::new(providers);
|
||||
assert!(
|
||||
registry.selectable().is_empty(),
|
||||
"user override removing setup should exclude from selectable"
|
||||
);
|
||||
// But find() should still work (uses the override)
|
||||
let def = registry
|
||||
.find("provider_a")
|
||||
.expect("should still be findable");
|
||||
assert_eq!(def.description, "No setup now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_preserves_order_with_dedup() {
|
||||
// If providers A, B, C are defined, and a user override for B comes
|
||||
// later, selectable() should return A, B, C (not A, C, B).
|
||||
let providers = vec![
|
||||
ProviderDefinition {
|
||||
id: "aaa".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://a/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "A".to_string(),
|
||||
default_model: "m".to_string(),
|
||||
description: "A".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::Ollama {
|
||||
display_name: "A".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "bbb".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://b/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "B".to_string(),
|
||||
default_model: "m".to_string(),
|
||||
description: "B-original".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::Ollama {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "ccc".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://c/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "C".to_string(),
|
||||
default_model: "m".to_string(),
|
||||
description: "C".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::Ollama {
|
||||
display_name: "C".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
},
|
||||
// User override for B
|
||||
ProviderDefinition {
|
||||
id: "bbb".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://b-new/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "B".to_string(),
|
||||
default_model: "m".to_string(),
|
||||
description: "B-override".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: Some(SetupHint::Ollama {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
let registry = ProviderRegistry::new(providers);
|
||||
let selectable = registry.selectable();
|
||||
let ids: Vec<&str> = selectable.iter().map(|d| d.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["aaa", "bbb", "ccc"], "order should be preserved");
|
||||
assert_eq!(
|
||||
selectable[1].description, "B-override",
|
||||
"should use the overridden definition"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_builtin_api_key_providers_have_api_key_env() {
|
||||
// Every built-in provider with SetupHint::ApiKey must have api_key_env
|
||||
// set, otherwise inject_llm_keys_from_secrets can't map the secret.
|
||||
let providers: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
for def in &providers {
|
||||
if let Some(SetupHint::ApiKey { .. }) = &def.setup {
|
||||
assert!(
|
||||
def.api_key_env.is_some(),
|
||||
"{}: ApiKey setup hint requires api_key_env to be set",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -10,8 +10,8 @@ use rig::completion::{
|
||||
ToolDefinition as RigToolDefinition, Usage as RigUsage,
|
||||
};
|
||||
use rig::message::{
|
||||
Message as RigMessage, ToolChoice as RigToolChoice, ToolFunction, ToolResult as RigToolResult,
|
||||
ToolResultContent, UserContent,
|
||||
DocumentSourceKind, Image, ImageMediaType, Message as RigMessage, ToolChoice as RigToolChoice,
|
||||
ToolFunction, ToolResult as RigToolResult, ToolResultContent, UserContent,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
@@ -230,7 +230,33 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
}
|
||||
}
|
||||
crate::llm::Role::User => {
|
||||
history.push(RigMessage::user(&msg.content));
|
||||
if msg.images.is_empty() {
|
||||
history.push(RigMessage::user(&msg.content));
|
||||
} else {
|
||||
// User message with images: create multi-part content
|
||||
let mut parts: Vec<UserContent> = vec![UserContent::text(&msg.content)];
|
||||
for img in &msg.images {
|
||||
let media_type = match img.media_type.to_lowercase().as_str() {
|
||||
"image/jpeg" => ImageMediaType::JPEG,
|
||||
"image/png" => ImageMediaType::PNG,
|
||||
"image/gif" => ImageMediaType::GIF,
|
||||
"image/webp" => ImageMediaType::WEBP,
|
||||
_ => ImageMediaType::JPEG,
|
||||
};
|
||||
parts.push(UserContent::Image(Image {
|
||||
data: DocumentSourceKind::Base64(img.data.clone()),
|
||||
media_type: Some(media_type),
|
||||
detail: None,
|
||||
additional_params: Default::default(),
|
||||
}));
|
||||
}
|
||||
if let Ok(many) = OneOrMany::many(parts) {
|
||||
history.push(RigMessage::User { content: many });
|
||||
} else {
|
||||
// Fallback to text only
|
||||
history.push(RigMessage::user(&msg.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::llm::Role::Assistant => {
|
||||
if let Some(ref tool_calls) = msg.tool_calls {
|
||||
@@ -635,6 +661,7 @@ mod tests {
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
images: vec![],
|
||||
}];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
match &history[0] {
|
||||
@@ -784,6 +811,7 @@ mod tests {
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
images: vec![],
|
||||
};
|
||||
let messages = vec![assistant_msg, tool_result_msg];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Detection of vision-capable models across inference providers.
|
||||
|
||||
/// Check if a model name indicates vision capability.
|
||||
///
|
||||
/// Detects models like:
|
||||
/// - Claude (Anthropic): `claude-opus`, `claude-sonnet`, etc.
|
||||
/// - GPT (OpenAI): `gpt-4-vision`, `gpt-4-turbo`, `gpt-4o`, etc.
|
||||
/// - Gemini (Google): `gemini-pro-vision`, `gemini-2.0-flash`, etc.
|
||||
/// - Llama (Meta): `llama-2-vision`, etc.
|
||||
/// - Other vision-capable models
|
||||
pub fn is_vision_model(model: &str) -> bool {
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
// Claude models (Anthropic)
|
||||
if model_lower.contains("claude") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// GPT-4 models with vision support
|
||||
if (model_lower.contains("gpt-4")
|
||||
|| model_lower.contains("gpt-4o")
|
||||
|| model_lower.contains("gpt-4-turbo")
|
||||
|| model_lower.contains("gpt-4-vision"))
|
||||
&& !model_lower.contains("gpt-4-mini")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Gemini models
|
||||
if model_lower.contains("gemini") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Llava and other vision models
|
||||
if model_lower.contains("llava")
|
||||
|| model_lower.contains("vision")
|
||||
|| model_lower.contains("multimodal")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if any model in a list is a vision-capable model.
|
||||
pub fn has_vision_model(models: &[String]) -> bool {
|
||||
models.iter().any(|m| is_vision_model(m))
|
||||
}
|
||||
|
||||
/// Suggest the best vision model from available models.
|
||||
///
|
||||
/// Priority: Claude > GPT-4 > Gemini > others
|
||||
pub fn suggest_vision_model(models: &[String]) -> Option<String> {
|
||||
// Prefer Claude
|
||||
if let Some(claude) = models.iter().find(|m| m.to_lowercase().contains("claude")) {
|
||||
return Some(claude.clone());
|
||||
}
|
||||
|
||||
// Then GPT-4
|
||||
if let Some(gpt4) = models
|
||||
.iter()
|
||||
.find(|m| m.to_lowercase().contains("gpt-4") && !m.to_lowercase().contains("gpt-4-mini"))
|
||||
{
|
||||
return Some(gpt4.clone());
|
||||
}
|
||||
|
||||
// Then Gemini
|
||||
if let Some(gemini) = models.iter().find(|m| m.to_lowercase().contains("gemini")) {
|
||||
return Some(gemini.clone());
|
||||
}
|
||||
|
||||
// Then any other vision model
|
||||
models.iter().find(|m| is_vision_model(m)).cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_claude_detection() {
|
||||
assert!(is_vision_model("claude-opus-4-20250514"));
|
||||
assert!(is_vision_model("claude-sonnet-4-20250514"));
|
||||
assert!(is_vision_model("claude-haiku-3-5-sonnet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt4_detection() {
|
||||
assert!(is_vision_model("gpt-4-turbo"));
|
||||
assert!(is_vision_model("gpt-4o"));
|
||||
assert!(is_vision_model("gpt-4-vision"));
|
||||
assert!(is_vision_model("gpt-4-32k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt4_mini_not_vision() {
|
||||
assert!(!is_vision_model("gpt-4-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_detection() {
|
||||
assert!(is_vision_model("gemini-pro-vision"));
|
||||
assert!(is_vision_model("gemini-2.0-flash"));
|
||||
assert!(is_vision_model("gemini-1.5-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_llava_detection() {
|
||||
assert!(is_vision_model("llava-1.6"));
|
||||
assert!(is_vision_model("llava-v1-7b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multimodal_detection() {
|
||||
assert!(is_vision_model("my-multimodal-model"));
|
||||
assert!(is_vision_model("custom-vision-model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_vision_models() {
|
||||
assert!(!is_vision_model("text-davinci-3"));
|
||||
assert!(!is_vision_model("llama-2-7b"));
|
||||
assert!(!is_vision_model("mistral-7b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_vision_model() {
|
||||
let models = vec![
|
||||
"gpt-4-turbo".to_string(),
|
||||
"claude-opus-4-20250514".to_string(),
|
||||
"gemini-2.0-flash".to_string(),
|
||||
];
|
||||
|
||||
// Should prefer Claude
|
||||
assert_eq!(
|
||||
suggest_vision_model(&models),
|
||||
Some("claude-opus-4-20250514".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_gpt4_when_no_claude() {
|
||||
let models = vec!["gpt-4-turbo".to_string(), "gemini-2.0-flash".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
suggest_vision_model(&models),
|
||||
Some("gpt-4-turbo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_gemini_when_no_claude_or_gpt4() {
|
||||
let models = vec!["gemini-2.0-flash".to_string(), "text-davinci-3".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
suggest_vision_model(&models),
|
||||
Some("gemini-2.0-flash".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-35
@@ -23,7 +23,7 @@ use ironclaw::{
|
||||
},
|
||||
config::Config,
|
||||
hooks::bootstrap_hooks,
|
||||
llm::{SessionConfig, create_session_manager},
|
||||
llm::create_session_manager,
|
||||
orchestrator::{
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
||||
api::OrchestratorState,
|
||||
@@ -121,19 +121,21 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
Some(Command::Onboard {
|
||||
skip_auth,
|
||||
channels_only,
|
||||
provider_only,
|
||||
}) => {
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
{
|
||||
let config = SetupConfig {
|
||||
skip_auth: *skip_auth,
|
||||
channels_only: *channels_only,
|
||||
provider_only: *provider_only,
|
||||
};
|
||||
let mut wizard = SetupWizard::with_config(config);
|
||||
wizard.run().await?;
|
||||
}
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
let _ = (skip_auth, channels_only);
|
||||
let _ = (skip_auth, channels_only, provider_only);
|
||||
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
||||
}
|
||||
return Ok(());
|
||||
@@ -172,12 +174,8 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Initialize session manager and authenticate before channel setup
|
||||
let session_config = SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
};
|
||||
let session = create_session_manager(session_config).await;
|
||||
// Initialize session manager before channel setup
|
||||
let session = create_session_manager(config.llm.session.clone()).await;
|
||||
|
||||
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
|
||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||
@@ -206,13 +204,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
let config = components.config;
|
||||
|
||||
// Session-based auth is only needed for NEAR AI backend without an API key.
|
||||
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
|
||||
&& config.llm.nearai.api_key.is_none()
|
||||
{
|
||||
session.ensure_authenticated().await?;
|
||||
}
|
||||
|
||||
// ── Tunnel setup ───────────────────────────────────────────────────
|
||||
|
||||
let (config, active_tunnel) = start_tunnel(config).await;
|
||||
@@ -738,31 +729,12 @@ async fn run_memory_command(mem_cmd: &ironclaw::cli::MemoryCommand) -> anyhow::R
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let session = create_session_manager(SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
})
|
||||
.await;
|
||||
let session = create_session_manager(config.llm.session.clone()).await;
|
||||
|
||||
let embeddings = config
|
||||
.embeddings
|
||||
.create_provider(&config.llm.nearai.base_url, session);
|
||||
|
||||
// Warn if libSQL backend is used with non-1536 embedding dimension.
|
||||
if config.database.backend == ironclaw::config::DatabaseBackend::LibSql
|
||||
&& config.embeddings.enabled
|
||||
&& config.embeddings.dimension != 1536
|
||||
{
|
||||
tracing::warn!(
|
||||
configured_dimension = config.embeddings.dimension,
|
||||
"Embedding dimension {} is not 1536. The libSQL schema uses \
|
||||
F32_BLOB(1536) which requires exactly 1536 dimensions. \
|
||||
Embedding storage will fail. Use PostgreSQL or set \
|
||||
EMBEDDING_DIMENSION=1536.",
|
||||
config.embeddings.dimension
|
||||
);
|
||||
}
|
||||
|
||||
let db: Arc<dyn ironclaw::db::Database> = ironclaw::db::connect_from_config(&config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::Docker;
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
use crate::secrets::SecretError;
|
||||
|
||||
/// Service name for keychain entries.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const SERVICE_NAME: &str = "ironclaw";
|
||||
|
||||
/// Account name for the master key.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const MASTER_KEY_ACCOUNT: &str = "master_key";
|
||||
|
||||
/// Generate a random 32-byte master key.
|
||||
@@ -261,6 +263,7 @@ mod platform {
|
||||
pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key};
|
||||
|
||||
/// Parse a hex string to bytes.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
|
||||
if !hex.len().is_multiple_of(2) {
|
||||
return Err(SecretError::KeychainError(
|
||||
|
||||
@@ -309,6 +309,7 @@ async fn setup_tunnel_cloudflare() -> Result<TunnelSettings, ChannelSetupError>
|
||||
/// Detect running cloudflared processes or managed services that could conflict
|
||||
/// with IronClaw's tunnel management.
|
||||
fn detect_existing_cloudflared() -> Option<String> {
|
||||
#[allow(unused_mut)]
|
||||
let mut conflicts: Vec<String> = Vec::new();
|
||||
|
||||
// Check for running cloudflared processes (all platforms)
|
||||
|
||||
+395
-211
@@ -73,6 +73,8 @@ pub struct SetupConfig {
|
||||
pub skip_auth: bool,
|
||||
/// Only reconfigure channels.
|
||||
pub channels_only: bool,
|
||||
/// Only reconfigure LLM provider and model selection.
|
||||
pub provider_only: bool,
|
||||
}
|
||||
|
||||
/// Interactive setup wizard for IronClaw.
|
||||
@@ -144,6 +146,16 @@ impl SetupWizard {
|
||||
self.reconnect_existing_db().await?;
|
||||
print_step(1, 1, "Channel Configuration");
|
||||
self.step_channels().await?;
|
||||
} else if self.config.provider_only {
|
||||
// Provider-only mode: reconnect to existing DB, then run just
|
||||
// inference provider + model selection steps.
|
||||
self.reconnect_existing_db().await?;
|
||||
print_step(1, 2, "Inference Provider");
|
||||
self.step_inference_provider().await?;
|
||||
self.persist_after_step().await;
|
||||
print_step(2, 2, "Model Selection");
|
||||
self.step_model_selection().await?;
|
||||
self.persist_after_step().await;
|
||||
} else {
|
||||
let total_steps = 9;
|
||||
|
||||
@@ -778,56 +790,31 @@ impl SetupWizard {
|
||||
|
||||
/// Step 3: Inference provider selection.
|
||||
///
|
||||
/// Lets the user pick from all supported LLM backends, then runs the
|
||||
/// provider-specific auth sub-flow (API key entry, NEAR AI login, etc.).
|
||||
/// Uses the provider registry to dynamically build the selection menu.
|
||||
/// NearAI is always first (special auth), then all registry providers
|
||||
/// that have setup hints.
|
||||
async fn step_inference_provider(&mut self) -> Result<(), SetupError> {
|
||||
// Show current provider if already configured
|
||||
if let Some(ref current) = self.settings.llm_backend {
|
||||
let is_openrouter = current == "openai_compatible"
|
||||
&& self
|
||||
.settings
|
||||
.openai_compatible_base_url
|
||||
.as_deref()
|
||||
.is_some_and(|u| u.contains("openrouter.ai"));
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
|
||||
let display = if is_openrouter {
|
||||
"OpenRouter"
|
||||
// Show current provider if already configured
|
||||
if let Some(current) = self.settings.llm_backend.clone() {
|
||||
let display = if current == "nearai" {
|
||||
"NEAR AI".to_string()
|
||||
} else if let Some(def) = registry.find(¤t) {
|
||||
def.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name().to_string())
|
||||
.unwrap_or_else(|| def.id.clone())
|
||||
} else {
|
||||
match current.as_str() {
|
||||
"nearai" => "NEAR AI",
|
||||
"anthropic" => "Anthropic (Claude)",
|
||||
"openai" => "OpenAI",
|
||||
"ollama" => "Ollama (local)",
|
||||
"openai_compatible" => "OpenAI-compatible endpoint",
|
||||
other => other,
|
||||
}
|
||||
current.clone()
|
||||
};
|
||||
print_info(&format!("Current provider: {}", display));
|
||||
println!();
|
||||
|
||||
let is_known = matches!(
|
||||
current.as_str(),
|
||||
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
|
||||
);
|
||||
let is_known = current == "nearai" || registry.is_known(¤t);
|
||||
|
||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||
// Still run the auth sub-flow in case they need to update keys
|
||||
if is_openrouter {
|
||||
return self.setup_openrouter().await;
|
||||
}
|
||||
match current.as_str() {
|
||||
"nearai" => return self.setup_nearai().await,
|
||||
"anthropic" => return self.setup_anthropic().await,
|
||||
"openai" => return self.setup_openai().await,
|
||||
"ollama" => return self.setup_ollama(),
|
||||
"openai_compatible" => return self.setup_openai_compatible().await,
|
||||
_ => {
|
||||
return Err(SetupError::Config(format!(
|
||||
"Unhandled provider: {}",
|
||||
current
|
||||
)));
|
||||
}
|
||||
}
|
||||
return self.run_provider_setup(¤t, ®istry).await;
|
||||
}
|
||||
|
||||
if !is_known {
|
||||
@@ -841,25 +828,105 @@ impl SetupWizard {
|
||||
print_info("Select your inference provider:");
|
||||
println!();
|
||||
|
||||
let options = &[
|
||||
"NEAR AI - multi-model access via NEAR account",
|
||||
"Anthropic - Claude models (direct API key)",
|
||||
"OpenAI - GPT models (direct API key)",
|
||||
"Ollama - local models, no API key needed",
|
||||
"OpenRouter - 200+ models via single API key",
|
||||
"OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)",
|
||||
];
|
||||
// Build menu: NearAI first, then all registry providers with setup hints
|
||||
let selectable = registry.selectable();
|
||||
let mut options: Vec<String> = Vec::with_capacity(1 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(1 + selectable.len());
|
||||
|
||||
let choice = select_one("Provider:", options).map_err(SetupError::Io)?;
|
||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||
provider_ids.push("nearai".to_string());
|
||||
|
||||
match choice {
|
||||
0 => self.setup_nearai().await?,
|
||||
1 => self.setup_anthropic().await?,
|
||||
2 => self.setup_openai().await?,
|
||||
3 => self.setup_ollama()?,
|
||||
4 => self.setup_openrouter().await?,
|
||||
5 => self.setup_openai_compatible().await?,
|
||||
_ => return Err(SetupError::Config("Invalid provider selection".to_string())),
|
||||
for def in &selectable {
|
||||
let label = format!(
|
||||
"{:<17}- {}",
|
||||
def.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name())
|
||||
.unwrap_or(&def.id),
|
||||
def.description
|
||||
);
|
||||
options.push(label);
|
||||
provider_ids.push(def.id.clone());
|
||||
}
|
||||
|
||||
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
|
||||
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
|
||||
let selected_id = &provider_ids[choice];
|
||||
|
||||
self.run_provider_setup(selected_id, ®istry).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the setup flow for a specific provider.
|
||||
///
|
||||
/// NearAI has its own special flow. Registry providers dispatch
|
||||
/// based on their `SetupHint` kind.
|
||||
async fn run_provider_setup(
|
||||
&mut self,
|
||||
provider_id: &str,
|
||||
registry: &crate::llm::ProviderRegistry,
|
||||
) -> Result<(), SetupError> {
|
||||
if provider_id == "nearai" {
|
||||
return self.setup_nearai().await;
|
||||
}
|
||||
|
||||
let def = registry
|
||||
.find(provider_id)
|
||||
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
|
||||
|
||||
// Providers without a setup hint (e.g., user-defined providers configured
|
||||
// purely via env vars) skip credential setup and go to model selection.
|
||||
let Some(setup) = def.setup.as_ref() else {
|
||||
print_info(&format!(
|
||||
"Provider '{}' has no setup wizard. Configure via environment variables.",
|
||||
provider_id
|
||||
));
|
||||
self.settings.llm_backend = Some(provider_id.to_string());
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match setup {
|
||||
crate::llm::registry::SetupHint::ApiKey {
|
||||
secret_name,
|
||||
key_url,
|
||||
display_name,
|
||||
..
|
||||
} => {
|
||||
let env_var = def.api_key_env.as_deref().unwrap_or("LLM_API_KEY");
|
||||
let url = key_url.as_deref().unwrap_or("the provider's website");
|
||||
|
||||
// Only store base URL for providers that resolve through
|
||||
// LLM_BASE_URL (openai_compatible, openrouter). Other providers
|
||||
// like groq/nvidia have their own base_url_env and don't need
|
||||
// this backward-compat setting.
|
||||
if def.base_url_env.as_deref() == Some("LLM_BASE_URL")
|
||||
&& let Some(ref base_url) = def.default_base_url
|
||||
{
|
||||
self.settings.openai_compatible_base_url = Some(base_url.clone());
|
||||
}
|
||||
|
||||
self.setup_api_key_provider(
|
||||
&def.id,
|
||||
env_var,
|
||||
secret_name,
|
||||
&format!("{display_name} API key"),
|
||||
url,
|
||||
Some(display_name),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
crate::llm::registry::SetupHint::Ollama { .. } => {
|
||||
self.setup_ollama_generic(def)?;
|
||||
}
|
||||
crate::llm::registry::SetupHint::OpenAiCompatible {
|
||||
secret_name,
|
||||
display_name,
|
||||
..
|
||||
} => {
|
||||
self.setup_openai_compatible_generic(&def.id, secret_name, display_name)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -924,33 +991,7 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Anthropic provider setup: collect API key and store in secrets.
|
||||
async fn setup_anthropic(&mut self) -> Result<(), SetupError> {
|
||||
self.setup_api_key_provider(
|
||||
"anthropic",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"llm_anthropic_api_key",
|
||||
"Anthropic API key",
|
||||
"https://console.anthropic.com/settings/keys",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// OpenAI provider setup: collect API key and store in secrets.
|
||||
async fn setup_openai(&mut self) -> Result<(), SetupError> {
|
||||
self.setup_api_key_provider(
|
||||
"openai",
|
||||
"OPENAI_API_KEY",
|
||||
"llm_openai_api_key",
|
||||
"OpenAI API key",
|
||||
"https://platform.openai.com/api-keys",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shared setup flow for API-key-based providers (Anthropic, OpenAI, OpenRouter).
|
||||
/// Shared setup flow for API-key-based providers.
|
||||
async fn setup_api_key_provider(
|
||||
&mut self,
|
||||
backend: &str,
|
||||
@@ -1018,9 +1059,12 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ollama provider setup: just needs a base URL, no API key.
|
||||
fn setup_ollama(&mut self) -> Result<(), SetupError> {
|
||||
self.settings.llm_backend = Some("ollama".to_string());
|
||||
/// Generic Ollama-style setup: just needs a base URL, no API key.
|
||||
fn setup_ollama_generic(
|
||||
&mut self,
|
||||
def: &crate::llm::ProviderDefinition,
|
||||
) -> Result<(), SetupError> {
|
||||
self.settings.llm_backend = Some(def.id.clone());
|
||||
if self.settings.selected_model.is_some() {
|
||||
self.settings.selected_model = None;
|
||||
}
|
||||
@@ -1029,10 +1073,17 @@ impl SetupWizard {
|
||||
.settings
|
||||
.ollama_base_url
|
||||
.as_deref()
|
||||
.or(def.default_base_url.as_deref())
|
||||
.unwrap_or("http://localhost:11434");
|
||||
|
||||
let display_name = def
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|s| s.display_name())
|
||||
.unwrap_or(&def.id);
|
||||
|
||||
let url_input = optional_input(
|
||||
"Ollama base URL",
|
||||
&format!("{display_name} base URL"),
|
||||
Some(&format!("default: {}", default_url)),
|
||||
)
|
||||
.map_err(SetupError::Io)?;
|
||||
@@ -1040,31 +1091,18 @@ impl SetupWizard {
|
||||
let url = url_input.unwrap_or_else(|| default_url.to_string());
|
||||
self.settings.ollama_base_url = Some(url.clone());
|
||||
|
||||
print_success(&format!("Ollama configured ({})", url));
|
||||
print_success(&format!("{display_name} configured ({})", url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenRouter provider setup: pre-configured OpenAI-compatible endpoint.
|
||||
///
|
||||
/// Sets the base URL to `https://openrouter.ai/api/v1` and delegates
|
||||
/// API key collection to `setup_api_key_provider` with a display name
|
||||
/// override so messages say "OpenRouter" instead of "openai_compatible".
|
||||
async fn setup_openrouter(&mut self) -> Result<(), SetupError> {
|
||||
self.settings.openai_compatible_base_url = Some("https://openrouter.ai/api/v1".to_string());
|
||||
self.setup_api_key_provider(
|
||||
"openai_compatible",
|
||||
"LLM_API_KEY",
|
||||
"llm_compatible_api_key",
|
||||
"OpenRouter API key",
|
||||
"https://openrouter.ai/settings/keys",
|
||||
Some("OpenRouter"),
|
||||
)
|
||||
.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());
|
||||
/// Generic OpenAI-compatible setup: base URL + optional API key.
|
||||
async fn setup_openai_compatible_generic(
|
||||
&mut self,
|
||||
backend_id: &str,
|
||||
secret_name: &str,
|
||||
display_name: &str,
|
||||
) -> Result<(), SetupError> {
|
||||
self.settings.llm_backend = Some(backend_id.to_string());
|
||||
if self.settings.selected_model.is_some() {
|
||||
self.settings.selected_model = None;
|
||||
}
|
||||
@@ -1084,9 +1122,9 @@ impl SetupWizard {
|
||||
};
|
||||
|
||||
if url.is_empty() {
|
||||
return Err(SetupError::Config(
|
||||
"Base URL is required for OpenAI-compatible provider".to_string(),
|
||||
));
|
||||
return Err(SetupError::Config(format!(
|
||||
"Base URL is required for {display_name}"
|
||||
)));
|
||||
}
|
||||
|
||||
self.settings.openai_compatible_base_url = Some(url.clone());
|
||||
@@ -1098,19 +1136,17 @@ impl SetupWizard {
|
||||
|
||||
if !key_str.is_empty() {
|
||||
if let Ok(ctx) = self.init_secrets_context().await {
|
||||
ctx.save_secret("llm_compatible_api_key", &key)
|
||||
ctx.save_secret(secret_name, &key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SetupError::Config(format!("Failed to save API key: {}", e))
|
||||
})?;
|
||||
.map_err(|e| SetupError::Config(format!("Failed to save API key: {e}")))?;
|
||||
print_success("API key encrypted and saved");
|
||||
} else {
|
||||
print_info("Secrets not available. Set LLM_API_KEY in your environment.");
|
||||
print_info("Secrets not available. Set the API key in your environment.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_success(&format!("OpenAI-compatible configured ({})", url));
|
||||
print_success(&format!("{display_name} configured ({})", url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1135,73 +1171,120 @@ impl SetupWizard {
|
||||
}
|
||||
|
||||
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
|
||||
match backend {
|
||||
"anthropic" => {
|
||||
let cached = self
|
||||
if backend == "nearai" {
|
||||
// NEAR AI: use existing provider list_models()
|
||||
let fetched = self.fetch_nearai_models().await;
|
||||
let default_models: Vec<(String, String)> = vec![
|
||||
(
|
||||
"zai-org/GLM-latest".into(),
|
||||
"GLM Latest (default, fast)".into(),
|
||||
),
|
||||
(
|
||||
"anthropic::claude-sonnet-4-20250514".into(),
|
||||
"Claude Sonnet 4 (best quality)".into(),
|
||||
),
|
||||
(
|
||||
"openai::gpt-5.3-codex".into(),
|
||||
"GPT-5.3 Codex (flagship)".into(),
|
||||
),
|
||||
("openai::gpt-5.2".into(), "GPT-5.2".into()),
|
||||
("openai::gpt-4o".into(), "GPT-4o".into()),
|
||||
];
|
||||
|
||||
let models = if fetched.is_empty() {
|
||||
default_models
|
||||
} else {
|
||||
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
||||
};
|
||||
self.select_from_model_list(&models)?;
|
||||
} else if let Some(def) = registry.find(backend) {
|
||||
let can_list = def
|
||||
.setup
|
||||
.as_ref()
|
||||
.map(|s| s.can_list_models())
|
||||
.unwrap_or(false);
|
||||
|
||||
if can_list {
|
||||
// Try to fetch models from the provider's /v1/models endpoint
|
||||
let cached_key = self
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string());
|
||||
let models = fetch_anthropic_models(cached.as_deref()).await;
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
"openai" => {
|
||||
let cached = self
|
||||
.llm_api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string());
|
||||
let models = fetch_openai_models(cached.as_deref()).await;
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
"ollama" => {
|
||||
let base_url = self
|
||||
.settings
|
||||
.ollama_base_url
|
||||
.as_deref()
|
||||
.unwrap_or("http://localhost:11434");
|
||||
let models = fetch_ollama_models(base_url).await;
|
||||
|
||||
let models = match backend {
|
||||
"anthropic" => fetch_anthropic_models(cached_key.as_deref()).await,
|
||||
"openai" => fetch_openai_models(cached_key.as_deref()).await,
|
||||
"ollama" => {
|
||||
let base_url = self
|
||||
.settings
|
||||
.ollama_base_url
|
||||
.as_deref()
|
||||
.or(def.default_base_url.as_deref())
|
||||
.unwrap_or("http://localhost:11434");
|
||||
let models = fetch_ollama_models(base_url).await;
|
||||
if models.is_empty() {
|
||||
print_info("No models found. Pull one first: ollama pull llama3");
|
||||
}
|
||||
models
|
||||
}
|
||||
_ => {
|
||||
// Generic OpenAI-compatible model listing
|
||||
let base_url = def.default_base_url.as_deref().unwrap_or("");
|
||||
fetch_openai_compatible_models(base_url, cached_key.as_deref()).await
|
||||
}
|
||||
};
|
||||
|
||||
// Apply models_filter from setup hint (e.g., Groq "chat" filters non-chat models)
|
||||
let models =
|
||||
if let Some(filter) = def.setup.as_ref().and_then(|s| s.models_filter()) {
|
||||
let filter_lower = filter.to_lowercase();
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|(id, _)| id.to_lowercase().contains(&filter_lower))
|
||||
.collect()
|
||||
} else {
|
||||
models
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
print_info("No models found. Pull one first: ollama pull llama3");
|
||||
}
|
||||
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)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model name is required".to_string()));
|
||||
// Fall back to manual entry
|
||||
let default = &def.default_model;
|
||||
let model_id = input(&format!("Model name (default: {default})"))
|
||||
.map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
} else {
|
||||
self.select_from_model_list(&models)?;
|
||||
}
|
||||
} else {
|
||||
// Manual model entry
|
||||
let default = &def.default_model;
|
||||
let model_id =
|
||||
input(&format!("Model name (default: {default})")).map_err(SetupError::Io)?;
|
||||
let model_id = if model_id.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
_ => {
|
||||
// NEAR AI: use existing provider list_models()
|
||||
let fetched = self.fetch_nearai_models().await;
|
||||
let default_models: Vec<(String, String)> = vec![
|
||||
(
|
||||
"zai-org/GLM-latest".into(),
|
||||
"GLM Latest (default, fast)".into(),
|
||||
),
|
||||
(
|
||||
"anthropic::claude-sonnet-4-20250514".into(),
|
||||
"Claude Sonnet 4 (best quality)".into(),
|
||||
),
|
||||
(
|
||||
"openai::gpt-5.3-codex".into(),
|
||||
"GPT-5.3 Codex (flagship)".into(),
|
||||
),
|
||||
("openai::gpt-5.2".into(), "GPT-5.2".into()),
|
||||
("openai::gpt-4o".into(), "GPT-4o".into()),
|
||||
];
|
||||
|
||||
let models = if fetched.is_empty() {
|
||||
default_models
|
||||
} else {
|
||||
fetched.iter().map(|m| (m.clone(), m.clone())).collect()
|
||||
};
|
||||
self.select_from_model_list(&models)?;
|
||||
} else {
|
||||
// Unknown provider, manual entry
|
||||
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model name is required".to_string()));
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1254,13 +1337,15 @@ impl SetupWizard {
|
||||
.unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||
|
||||
let config = LlmConfig {
|
||||
backend: crate::config::LlmBackend::NearAi,
|
||||
backend: "nearai".to_string(),
|
||||
session: crate::llm::session::SessionConfig {
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
},
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
cheap_model: None,
|
||||
base_url,
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
@@ -1273,11 +1358,7 @@ impl SetupWizard {
|
||||
failover_cooldown_threshold: 3,
|
||||
smart_routing_cascade: true,
|
||||
},
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
tinfoil: None,
|
||||
provider: None,
|
||||
};
|
||||
|
||||
match create_llm_provider(&config, session) {
|
||||
@@ -2001,89 +2082,108 @@ impl SetupWizard {
|
||||
/// These are the chicken-and-egg settings needed before the database is
|
||||
/// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.).
|
||||
fn write_bootstrap_env(&self) -> Result<(), SetupError> {
|
||||
let mut env_vars: Vec<(&str, String)> = Vec::new();
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let mut env_vars: Vec<(String, String)> = Vec::new();
|
||||
|
||||
if let Some(ref backend) = self.settings.database_backend {
|
||||
env_vars.push(("DATABASE_BACKEND", backend.clone()));
|
||||
env_vars.push(("DATABASE_BACKEND".to_string(), backend.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.database_url {
|
||||
env_vars.push(("DATABASE_URL", url.clone()));
|
||||
env_vars.push(("DATABASE_URL".to_string(), url.clone()));
|
||||
}
|
||||
if let Some(ref path) = self.settings.libsql_path {
|
||||
env_vars.push(("LIBSQL_PATH", path.clone()));
|
||||
env_vars.push(("LIBSQL_PATH".to_string(), path.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.libsql_url {
|
||||
env_vars.push(("LIBSQL_URL", url.clone()));
|
||||
env_vars.push(("LIBSQL_URL".to_string(), url.clone()));
|
||||
}
|
||||
|
||||
// LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND.
|
||||
// Config::from_env() needs the backend before the DB is connected.
|
||||
if let Some(ref backend) = self.settings.llm_backend {
|
||||
env_vars.push(("LLM_BACKEND", backend.clone()));
|
||||
env_vars.push(("LLM_BACKEND".to_string(), backend.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.openai_compatible_base_url {
|
||||
env_vars.push(("LLM_BASE_URL", url.clone()));
|
||||
env_vars.push(("LLM_BASE_URL".to_string(), url.clone()));
|
||||
}
|
||||
if let Some(ref url) = self.settings.ollama_base_url {
|
||||
env_vars.push(("OLLAMA_BASE_URL", url.clone()));
|
||||
env_vars.push(("OLLAMA_BASE_URL".to_string(), url.clone()));
|
||||
}
|
||||
|
||||
// Model name: same chicken-and-egg — Config::from_env() resolves the
|
||||
// model before the DB is connected, so we must persist it to .env.
|
||||
// Write the backend-specific env var so the correct resolution path
|
||||
// picks it up.
|
||||
// picks it up (looked up from the provider registry).
|
||||
if let Some(ref model) = self.settings.selected_model {
|
||||
let backend: crate::config::LlmBackend = self
|
||||
.settings
|
||||
.llm_backend
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_default();
|
||||
env_vars.push((backend.model_env_var(), model.clone()));
|
||||
let backend_str = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
||||
let model_env = registry.model_env_var(backend_str);
|
||||
env_vars.push((model_env.to_string(), model.clone()));
|
||||
}
|
||||
|
||||
// Also write provider-specific base URL env var if the provider
|
||||
// defines one (e.g., GROQ doesn't need LLM_BASE_URL since its
|
||||
// default is compiled in, but it doesn't hurt to be explicit).
|
||||
if let Some(ref backend) = self.settings.llm_backend
|
||||
&& let Some(def) = registry.find(backend)
|
||||
&& let Some(ref base_url_env) = def.base_url_env
|
||||
&& let Some(ref base_url) = def.default_base_url
|
||||
&& base_url_env != "LLM_BASE_URL"
|
||||
&& base_url_env != "OLLAMA_BASE_URL"
|
||||
{
|
||||
env_vars.push((base_url_env.clone(), base_url.clone()));
|
||||
}
|
||||
|
||||
// Preserve NEARAI_API_KEY if present (set by API key auth flow)
|
||||
if let Ok(api_key) = std::env::var("NEARAI_API_KEY")
|
||||
&& !api_key.is_empty()
|
||||
{
|
||||
env_vars.push(("NEARAI_API_KEY", api_key));
|
||||
env_vars.push(("NEARAI_API_KEY".to_string(), api_key));
|
||||
}
|
||||
|
||||
// Always write ONBOARD_COMPLETED so that check_onboard_needed()
|
||||
// (which runs before the DB is connected) knows to skip re-onboarding.
|
||||
if self.settings.onboard_completed {
|
||||
env_vars.push(("ONBOARD_COMPLETED", "true".to_string()));
|
||||
env_vars.push(("ONBOARD_COMPLETED".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
// Signal channel env vars (chicken-and-egg: config resolves before DB).
|
||||
if let Some(ref url) = self.settings.channels.signal_http_url {
|
||||
env_vars.push(("SIGNAL_HTTP_URL", url.clone()));
|
||||
env_vars.push(("SIGNAL_HTTP_URL".to_string(), url.clone()));
|
||||
}
|
||||
if let Some(ref account) = self.settings.channels.signal_account {
|
||||
env_vars.push(("SIGNAL_ACCOUNT", account.clone()));
|
||||
env_vars.push(("SIGNAL_ACCOUNT".to_string(), account.clone()));
|
||||
}
|
||||
if let Some(ref allow_from) = self.settings.channels.signal_allow_from {
|
||||
env_vars.push(("SIGNAL_ALLOW_FROM", allow_from.clone()));
|
||||
env_vars.push(("SIGNAL_ALLOW_FROM".to_string(), allow_from.clone()));
|
||||
}
|
||||
if let Some(ref allow_from_groups) = self.settings.channels.signal_allow_from_groups
|
||||
&& !allow_from_groups.is_empty()
|
||||
{
|
||||
env_vars.push(("SIGNAL_ALLOW_FROM_GROUPS", allow_from_groups.clone()));
|
||||
env_vars.push((
|
||||
"SIGNAL_ALLOW_FROM_GROUPS".to_string(),
|
||||
allow_from_groups.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(ref dm_policy) = self.settings.channels.signal_dm_policy {
|
||||
env_vars.push(("SIGNAL_DM_POLICY", dm_policy.clone()));
|
||||
env_vars.push(("SIGNAL_DM_POLICY".to_string(), dm_policy.clone()));
|
||||
}
|
||||
if let Some(ref group_policy) = self.settings.channels.signal_group_policy {
|
||||
env_vars.push(("SIGNAL_GROUP_POLICY", group_policy.clone()));
|
||||
env_vars.push(("SIGNAL_GROUP_POLICY".to_string(), group_policy.clone()));
|
||||
}
|
||||
if let Some(ref group_allow_from) = self.settings.channels.signal_group_allow_from
|
||||
&& !group_allow_from.is_empty()
|
||||
{
|
||||
env_vars.push(("SIGNAL_GROUP_ALLOW_FROM", group_allow_from.clone()));
|
||||
env_vars.push((
|
||||
"SIGNAL_GROUP_ALLOW_FROM".to_string(),
|
||||
group_allow_from.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if !env_vars.is_empty() {
|
||||
let pairs: Vec<(&str, &str)> = env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
let pairs: Vec<(&str, &str)> = env_vars
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| {
|
||||
SetupError::Io(std::io::Error::other(format!(
|
||||
"Failed to save bootstrap env to .env: {}",
|
||||
@@ -2658,6 +2758,51 @@ async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch models from a generic OpenAI-compatible /v1/models endpoint.
|
||||
///
|
||||
/// Used for registry providers like Groq, NVIDIA NIM, etc.
|
||||
async fn fetch_openai_compatible_models(
|
||||
base_url: &str,
|
||||
cached_key: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
if base_url.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5));
|
||||
if let Some(key) = cached_key {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
|
||||
let resp = match req.send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Model {
|
||||
id: String,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Vec<Model>,
|
||||
}
|
||||
|
||||
match resp.json::<ModelsResponse>().await {
|
||||
Ok(body) => body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let label = m.id.clone();
|
||||
(m.id, label)
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover WASM channels in a directory.
|
||||
///
|
||||
/// Returns a list of (channel_name, capabilities_file) pairs.
|
||||
@@ -2948,6 +3093,7 @@ mod tests {
|
||||
let config = SetupConfig {
|
||||
skip_auth: true,
|
||||
channels_only: false,
|
||||
provider_only: false,
|
||||
};
|
||||
let wizard = SetupWizard::with_config(config);
|
||||
assert!(wizard.config.skip_auth);
|
||||
@@ -3144,4 +3290,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_provider_setup_no_setup_hint() {
|
||||
// A provider with setup: None should not error. It should set the
|
||||
// backend and return Ok, allowing env-var-only configured providers
|
||||
// to be kept during re-onboarding.
|
||||
let mut wizard = SetupWizard::new();
|
||||
|
||||
let mut providers: Vec<crate::llm::registry::ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
// Add a provider with no setup hint
|
||||
providers.push(crate::llm::registry::ProviderDefinition {
|
||||
id: "custom_no_setup".to_string(),
|
||||
aliases: vec![],
|
||||
protocol: crate::llm::registry::ProviderProtocol::OpenAiCompletions,
|
||||
default_base_url: Some("http://localhost:9999/v1".to_string()),
|
||||
base_url_env: None,
|
||||
base_url_required: false,
|
||||
api_key_env: None,
|
||||
api_key_required: false,
|
||||
model_env: "CUSTOM_MODEL".to_string(),
|
||||
default_model: "custom-model".to_string(),
|
||||
description: "Custom provider with no setup wizard".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
});
|
||||
let registry = crate::llm::ProviderRegistry::new(providers);
|
||||
|
||||
let result = wizard
|
||||
.run_provider_setup("custom_no_setup", ®istry)
|
||||
.await;
|
||||
assert!(result.is_ok(), "setup: None provider should not error");
|
||||
assert_eq!(
|
||||
wizard.settings.llm_backend.as_deref(),
|
||||
Some("custom_no_setup"),
|
||||
"backend should be set even without setup hint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Image analysis tool for vision-capable LLMs.
|
||||
//!
|
||||
//! Reads images from the workspace and prepares them for vision analysis.
|
||||
//! The LLM can then analyze the image content based on the user's query.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Tool for analyzing images using a vision-capable LLM.
|
||||
pub struct ImageAnalyzeTool {
|
||||
workspace: Arc<Workspace>,
|
||||
}
|
||||
|
||||
impl ImageAnalyzeTool {
|
||||
/// Create a new image analysis tool.
|
||||
pub fn new(workspace: Arc<Workspace>) -> Self {
|
||||
Self { workspace }
|
||||
}
|
||||
|
||||
/// Infer media type from file extension.
|
||||
fn infer_media_type(path: &str) -> &'static str {
|
||||
let lower_path = path.to_lowercase();
|
||||
if lower_path.ends_with(".png") || lower_path.ends_with(".b64") {
|
||||
"image/png"
|
||||
} else if lower_path.ends_with(".jpg") || lower_path.ends_with(".jpeg") {
|
||||
"image/jpeg"
|
||||
} else if lower_path.ends_with(".gif") {
|
||||
"image/gif"
|
||||
} else if lower_path.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/png" // Default to PNG
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ImageAnalyzeTool {
|
||||
fn name(&self) -> &str {
|
||||
"image_analyze"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Analyze an image using the LLM's vision capabilities. Provide the workspace path to the image and a question or prompt about what you want to know about the image."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Workspace path to the image (e.g., 'images/generated/abc123.b64')"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What do you want to know about the image? (e.g., 'describe the objects in this image', 'is there text in this image?')"
|
||||
}
|
||||
},
|
||||
"required": ["path", "query"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, params: Value, _ctx: &JobContext) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Parse parameters
|
||||
let path = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing or invalid 'path' parameter".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let query = params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing or invalid 'query' parameter".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
if query.is_empty() {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"Query cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Read image from workspace
|
||||
let doc = self.workspace.read(&path).await.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to read image from workspace: {}", e))
|
||||
})?;
|
||||
|
||||
// Infer media type from path
|
||||
let media_type = Self::infer_media_type(&path).to_string();
|
||||
|
||||
// Return the image data and query so the agent can include the image in its vision analysis
|
||||
Ok(ToolOutput::success(
|
||||
json!({
|
||||
"type": "image_analysis_ready",
|
||||
"path": path,
|
||||
"query": query,
|
||||
"data": doc.content,
|
||||
"media_type": media_type,
|
||||
"instruction": format!("The user wants you to analyze this image with the following query: {}", query)
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &Value) -> ApprovalRequirement {
|
||||
// Image analysis is read-only, no approval needed
|
||||
ApprovalRequirement::Never
|
||||
}
|
||||
|
||||
fn sensitive_params(&self) -> &[&str] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_png() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.png"),
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.b64"),
|
||||
"image/png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_jpeg() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.jpg"),
|
||||
"image/jpeg"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.jpeg"),
|
||||
"image/jpeg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_gif() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.gif"),
|
||||
"image/gif"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_webp() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.webp"),
|
||||
"image/webp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_default() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.unknown"),
|
||||
"image/png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parameters_schema_required_fields() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Workspace path to the image (e.g., 'images/generated/abc123.b64')"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What do you want to know about the image? (e.g., 'describe the objects in this image', 'is there text in this image?')"
|
||||
}
|
||||
},
|
||||
"required": ["path", "query"]
|
||||
});
|
||||
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["path"].is_object());
|
||||
assert!(schema["properties"]["query"].is_object());
|
||||
assert_eq!(schema["required"], json!(["path", "query"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_uppercase_extension_defaults() {
|
||||
// Uppercase extensions are now case-insensitively matched
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.PNG"),
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/test.JPG"),
|
||||
"image/jpeg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_nested_path() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/generated/2024-03-06/deep/nested/image.png"),
|
||||
"image/png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_media_type_multiple_dots() {
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/my.test.image.png"),
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
ImageAnalyzeTool::infer_media_type("images/file.backup.jpg"),
|
||||
"image/jpeg"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Image editing tool using NEAR AI cloud-api (FLUX model).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::NearAiConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRateLimitConfig};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Tool for editing existing images using NEAR AI cloud-api (FLUX).
|
||||
pub struct ImageEditTool {
|
||||
config: NearAiConfig,
|
||||
client: reqwest::Client,
|
||||
workspace: Arc<Workspace>,
|
||||
}
|
||||
|
||||
impl ImageEditTool {
|
||||
/// Create a new image editing tool.
|
||||
pub fn new(config: NearAiConfig, workspace: Arc<Workspace>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
workspace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ImageEditTool {
|
||||
fn name(&self) -> &str {
|
||||
"image_edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Edit an existing image using NEAR AI cloud-api (FLUX) by providing the workspace path and a description of changes. \
|
||||
Returns the edited image saved to the workspace."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Workspace path to the source image (e.g., 'images/generated/abc123.b64')"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Description of the edits to apply (max 4000 characters)"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"enum": ["1024x1024", "1792x1024", "1024x1792"],
|
||||
"description": "Image dimensions. Default: 1024x1024"
|
||||
}
|
||||
},
|
||||
"required": ["path", "prompt"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, params: Value, _ctx: &JobContext) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Parse parameters
|
||||
let path = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing or invalid 'path' parameter".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let prompt = params
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing or invalid 'prompt' parameter".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
if prompt.is_empty() {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"Prompt cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if prompt.len() > 4000 {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"Prompt exceeds 4000 character limit (got {})",
|
||||
prompt.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let size = params
|
||||
.get("size")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("1024x1024");
|
||||
|
||||
// Read base64 image data from workspace
|
||||
let doc = self.workspace.read(&path).await.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to read image from workspace: {}", e))
|
||||
})?;
|
||||
|
||||
// Decode base64 to bytes
|
||||
let image_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&doc.content)
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to decode base64 image data: {}", e))
|
||||
})?;
|
||||
|
||||
// Build multipart form
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("model", "black-forest-labs/FLUX.2-klein-4B")
|
||||
.part(
|
||||
"image",
|
||||
reqwest::multipart::Part::bytes(image_bytes).file_name("image.png"),
|
||||
)
|
||||
.text("prompt", prompt.clone())
|
||||
.text("n", "1")
|
||||
.text("size", size.to_string())
|
||||
.text("response_format", "b64_json");
|
||||
|
||||
// Call NEAR AI cloud-api edit endpoint
|
||||
let endpoint = format!(
|
||||
"{}/v1/images/edits",
|
||||
self.config.base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let auth_header = if let Some(api_key) = &self.config.api_key {
|
||||
format!("Bearer {}", api_key.expose_secret())
|
||||
} else {
|
||||
"Bearer ".to_string()
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Authorization", auth_header)
|
||||
.multipart(form)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExternalService(format!("NEAR AI image edit failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(ToolError::ExternalService(format!(
|
||||
"NEAR AI image edit error ({}): {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let response_json: Value = response.json().await.map_err(|e| {
|
||||
ToolError::ExternalService(format!("Failed to parse NEAR AI response: {}", e))
|
||||
})?;
|
||||
|
||||
// Extract base64 edited image data
|
||||
let edited_base64 = response_json
|
||||
.get("data")
|
||||
.and_then(|d| d.get(0))
|
||||
.and_then(|item| item.get("b64_json"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExternalService(
|
||||
"Invalid NEAR AI response structure: missing base64 data".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
// Generate unique filename for edited image
|
||||
let edit_id = Uuid::new_v4().to_string();
|
||||
let filename = format!("images/generated/{}_edit.png", edit_id);
|
||||
|
||||
// Store edited image to workspace
|
||||
let edit_path = format!("images/generated/{}_edit.b64", edit_id);
|
||||
self.workspace
|
||||
.write(&edit_path, &edited_base64)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Failed to save edited image to workspace: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Return sentinel JSON for agent_loop to detect and emit SSE event
|
||||
Ok(ToolOutput::success(
|
||||
json!({
|
||||
"type": "image_generated",
|
||||
"path": edit_path,
|
||||
"data": edited_base64,
|
||||
"media_type": "image/png",
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"filename": filename,
|
||||
"source_path": path
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &Value) -> ApprovalRequirement {
|
||||
// Image editing is read-only on external state
|
||||
ApprovalRequirement::Never
|
||||
}
|
||||
|
||||
fn rate_limit_config(&self) -> Option<ToolRateLimitConfig> {
|
||||
// DALL-E is expensive; rate limit aggressively
|
||||
Some(ToolRateLimitConfig::new(6, 30))
|
||||
}
|
||||
|
||||
fn sensitive_params(&self) -> &[&str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn execution_timeout(&self) -> std::time::Duration {
|
||||
// Image editing can take 2+ minutes on the NEAR AI cloud-api
|
||||
std::time::Duration::from_secs(180)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Image generation tool using NEAR AI cloud-api (FLUX model).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::NearAiConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRateLimitConfig};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Tool for generating images from text prompts using NEAR AI cloud-api (FLUX).
|
||||
pub struct ImageGenerateTool {
|
||||
config: NearAiConfig,
|
||||
client: reqwest::Client,
|
||||
workspace: Arc<Workspace>,
|
||||
}
|
||||
|
||||
impl ImageGenerateTool {
|
||||
/// Create a new image generation tool.
|
||||
pub fn new(config: NearAiConfig, workspace: Arc<Workspace>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
workspace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ImageGenerateTool {
|
||||
fn name(&self) -> &str {
|
||||
"image_generate"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate an image from a text prompt using NEAR AI cloud-api (FLUX.2-klein-4B). \
|
||||
Returns the generated image saved to the workspace."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Detailed text description of the image to generate (max 4000 characters)"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"enum": ["1024x1024", "1792x1024", "1024x1792"],
|
||||
"description": "Image dimensions. Default: 1024x1024"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, params: Value, _ctx: &JobContext) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Parse parameters
|
||||
let prompt = params
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("Missing or invalid 'prompt' parameter".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
if prompt.is_empty() {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"Prompt cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if prompt.len() > 4000 {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"Prompt exceeds 4000 character limit (got {})",
|
||||
prompt.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let size = params
|
||||
.get("size")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("1024x1024");
|
||||
|
||||
// Call NEAR AI cloud-api for image generation (FLUX model)
|
||||
let request_body = json!({
|
||||
"model": "black-forest-labs/FLUX.2-klein-4B",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"response_format": "b64_json"
|
||||
});
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/v1/images/generations",
|
||||
self.config.base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let auth_header = if let Some(api_key) = &self.config.api_key {
|
||||
format!("Bearer {}", api_key.expose_secret())
|
||||
} else {
|
||||
// Fallback: use default NEAR AI cloud-api without explicit key
|
||||
// (expects auth via environment or other mechanism)
|
||||
"Bearer ".to_string()
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Authorization", auth_header)
|
||||
.json(&request_body)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExternalService(format!("NEAR AI image generation failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(ToolError::ExternalService(format!(
|
||||
"NEAR AI image generation error ({}): {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let response_json: Value = response.json().await.map_err(|e| {
|
||||
ToolError::ExternalService(format!("Failed to parse NEAR AI response: {}", e))
|
||||
})?;
|
||||
|
||||
// Extract base64 image data
|
||||
let base64_data = response_json
|
||||
.get("data")
|
||||
.and_then(|d| d.get(0))
|
||||
.and_then(|item| item.get("b64_json"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExternalService(
|
||||
"Invalid NEAR AI response structure: missing base64 data".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
// Generate unique filename
|
||||
let image_id = Uuid::new_v4().to_string();
|
||||
let filename = format!("images/generated/{}.png", image_id);
|
||||
|
||||
// Store the image file (with extension) containing the base64 data
|
||||
let image_path = format!("images/generated/{}.b64", image_id);
|
||||
self.workspace
|
||||
.write(&image_path, &base64_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("Failed to save image to workspace: {}", e))
|
||||
})?;
|
||||
|
||||
// Return sentinel JSON for agent_loop to detect and emit SSE event
|
||||
Ok(ToolOutput::success(
|
||||
json!({
|
||||
"type": "image_generated",
|
||||
"path": image_path,
|
||||
"data": base64_data,
|
||||
"media_type": "image/png",
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"filename": filename
|
||||
}),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &Value) -> ApprovalRequirement {
|
||||
// Image generation from a prompt is read-only on external state
|
||||
// so no approval needed
|
||||
ApprovalRequirement::Never
|
||||
}
|
||||
|
||||
fn rate_limit_config(&self) -> Option<ToolRateLimitConfig> {
|
||||
// DALL-E is expensive; rate limit aggressively
|
||||
Some(ToolRateLimitConfig::new(6, 30))
|
||||
}
|
||||
|
||||
fn sensitive_params(&self) -> &[&str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn execution_timeout(&self) -> std::time::Duration {
|
||||
// Image generation can take 2+ minutes on the NEAR AI cloud-api
|
||||
std::time::Duration::from_secs(180)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ mod echo;
|
||||
pub mod extension_tools;
|
||||
mod file;
|
||||
mod http;
|
||||
mod image_analyze;
|
||||
mod image_edit;
|
||||
mod image_gen;
|
||||
mod job;
|
||||
mod json;
|
||||
mod memory;
|
||||
@@ -23,6 +26,9 @@ pub use extension_tools::{
|
||||
};
|
||||
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||
pub use http::HttpTool;
|
||||
pub use image_analyze::ImageAnalyzeTool;
|
||||
pub use image_edit::ImageEditTool;
|
||||
pub use image_gen::ImageGenerateTool;
|
||||
pub use job::{
|
||||
CancelJobTool, CreateJobTool, JobEventsTool, JobPromptTool, JobStatusTool, ListJobsTool,
|
||||
PromptQueue, SchedulerSlot,
|
||||
|
||||
+36
-5
@@ -17,11 +17,11 @@ use crate::skills::registry::SkillRegistry;
|
||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||
use crate::tools::builtin::{
|
||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
||||
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
|
||||
MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool,
|
||||
ShellTool, SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool,
|
||||
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
||||
WriteFileTool,
|
||||
ImageEditTool, ImageGenerateTool, JobEventsTool, JobPromptTool, JobStatusTool, JsonTool,
|
||||
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
|
||||
PromptQueue, ReadFileTool, ShellTool, SkillInstallTool, SkillListTool, SkillRemoveTool,
|
||||
SkillSearchTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
|
||||
ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
||||
};
|
||||
use crate::tools::rate_limiter::RateLimiter;
|
||||
use crate::tools::tool::{Tool, ToolDomain};
|
||||
@@ -71,6 +71,9 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
"message",
|
||||
"web_fetch",
|
||||
"restart",
|
||||
"image_generate",
|
||||
"image_edit",
|
||||
"image_analyze",
|
||||
];
|
||||
|
||||
/// Registry of available tools.
|
||||
@@ -302,6 +305,34 @@ impl ToolRegistry {
|
||||
tracing::info!("Registered 4 memory tools");
|
||||
}
|
||||
|
||||
/// Register image generation tools with NEAR AI config and workspace.
|
||||
///
|
||||
/// Image tools require NEAR AI cloud-api access and workspace for storing generated images.
|
||||
pub fn register_image_tools(
|
||||
&self,
|
||||
config: crate::config::NearAiConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
) {
|
||||
self.register_sync(Arc::new(ImageGenerateTool::new(
|
||||
config.clone(),
|
||||
Arc::clone(&workspace),
|
||||
)));
|
||||
self.register_sync(Arc::new(ImageEditTool::new(config, workspace)));
|
||||
|
||||
tracing::info!("Registered 2 image tools (NEAR AI FLUX)");
|
||||
}
|
||||
|
||||
/// Register image analysis tool with workspace access.
|
||||
///
|
||||
/// Vision tool allows analyzing images using the LLM's vision capabilities.
|
||||
pub fn register_vision_tools(&self, workspace: Arc<Workspace>) {
|
||||
self.register_sync(Arc::new(crate::tools::builtin::ImageAnalyzeTool::new(
|
||||
workspace,
|
||||
)));
|
||||
|
||||
tracing::info!("Registered 1 vision tool (image analysis)");
|
||||
}
|
||||
|
||||
/// Register job management tools.
|
||||
///
|
||||
/// Job tools allow the LLM to create, list, check status, and cancel jobs.
|
||||
|
||||
@@ -102,7 +102,7 @@ pub use limits::{
|
||||
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
|
||||
WasmResourceLimiter,
|
||||
};
|
||||
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime};
|
||||
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime, enable_compilation_cache};
|
||||
pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
|
||||
|
||||
// Capabilities (V2)
|
||||
|
||||
+118
-2
@@ -4,7 +4,7 @@
|
||||
//! This matches NEAR blockchain patterns for deterministic, isolated execution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -18,6 +18,58 @@ use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
|
||||
/// which causes any store with an expired epoch deadline to trap.
|
||||
pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Enable wasmtime's persistent compilation cache for a [`Config`].
|
||||
///
|
||||
/// On Unix, this delegates to `cache_config_load_default()` which uses a
|
||||
/// shared cache directory. On Windows, each engine gets its own subdirectory
|
||||
/// (keyed by `label`) to avoid OS error 33 (`ERROR_LOCK_VIOLATION`) when
|
||||
/// multiple engines memory-map files in the same cache directory. See #448.
|
||||
///
|
||||
/// If `explicit_dir` is `Some`, it is used as the cache directory on all
|
||||
/// platforms, bypassing the default.
|
||||
pub fn enable_compilation_cache(
|
||||
wasmtime_config: &mut Config,
|
||||
label: &str,
|
||||
explicit_dir: Option<&Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
// If the caller provided an explicit directory, or we're on Windows and
|
||||
// need per-engine isolation, write a TOML config with a custom directory.
|
||||
let custom_dir = match explicit_dir {
|
||||
Some(dir) => Some(dir.to_path_buf()),
|
||||
#[cfg(windows)]
|
||||
None => {
|
||||
let base = dirs::cache_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("ironclaw");
|
||||
Some(base.join(format!("wasmtime-{}", label)))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
None => {
|
||||
let _ = label;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
match custom_dir {
|
||||
Some(dir) => {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let toml_path = dir.join("wasmtime-cache.toml");
|
||||
let escaped = dir
|
||||
.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"");
|
||||
let toml_content = format!("[cache]\nenabled = true\ndirectory = \"{}\"\n", escaped);
|
||||
std::fs::write(&toml_path, toml_content)?;
|
||||
wasmtime_config.cache_config_load(&toml_path)?;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
wasmtime_config.cache_config_load_default()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the WASM runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WasmRuntimeConfig {
|
||||
@@ -136,7 +188,14 @@ impl WasmToolRuntime {
|
||||
// Enable persistent compilation cache. Wasmtime serializes compiled native
|
||||
// code to disk (~/.cache/wasmtime by default), so subsequent startups
|
||||
// deserialize instead of recompiling — typically 10-50x faster.
|
||||
if let Err(e) = wasmtime_config.cache_config_load_default() {
|
||||
//
|
||||
// On Windows, each Engine gets its own cache subdirectory to avoid
|
||||
// OS error 33 (ERROR_LOCK_VIOLATION) when multiple engines share the
|
||||
// default cache and Windows holds exclusive locks on memory-mapped
|
||||
// files. See #448.
|
||||
if let Err(e) =
|
||||
enable_compilation_cache(&mut wasmtime_config, "tools", config.cache_dir.as_deref())
|
||||
{
|
||||
tracing::warn!("Failed to enable wasmtime compilation cache: {}", e);
|
||||
}
|
||||
|
||||
@@ -348,6 +407,63 @@ mod tests {
|
||||
assert_eq!(limits.fuel, 500_000);
|
||||
}
|
||||
|
||||
/// Per-engine cache directories must work correctly to avoid file lock
|
||||
/// conflicts on Windows where multiple engines sharing a single cache
|
||||
/// directory triggers OS error 33 (ERROR_LOCK_VIOLATION). Regression test
|
||||
/// for #448: `enable_compilation_cache` must create a subdirectory and
|
||||
/// produce a valid TOML config that wasmtime can load.
|
||||
#[test]
|
||||
fn test_enable_compilation_cache_with_explicit_dir() {
|
||||
use crate::tools::wasm::runtime::enable_compilation_cache;
|
||||
|
||||
let tmp = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let cache_dir = tmp.path().join("custom-cache");
|
||||
|
||||
let mut config = wasmtime::Config::new();
|
||||
enable_compilation_cache(&mut config, "test-engine", Some(cache_dir.as_path()))
|
||||
.expect("enable_compilation_cache should succeed with explicit dir");
|
||||
|
||||
// The cache directory should have been created.
|
||||
assert!(cache_dir.exists(), "cache directory should be created");
|
||||
|
||||
// A TOML config file should have been written inside.
|
||||
let toml_path = cache_dir.join("wasmtime-cache.toml");
|
||||
assert!(toml_path.exists(), "TOML config should be written");
|
||||
|
||||
let content = std::fs::read_to_string(&toml_path).unwrap();
|
||||
assert!(
|
||||
content.contains("[cache]"),
|
||||
"TOML must contain [cache] section"
|
||||
);
|
||||
assert!(content.contains("enabled = true"), "cache must be enabled");
|
||||
}
|
||||
|
||||
/// Two engines with different labels must get independent cache directories
|
||||
/// so that their file locks do not conflict. Regression test for #448.
|
||||
#[test]
|
||||
fn test_enable_compilation_cache_label_isolation() {
|
||||
use crate::tools::wasm::runtime::enable_compilation_cache;
|
||||
|
||||
let tmp = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let base = tmp.path().join("isolation");
|
||||
|
||||
let dir_a = base.join("engine-a");
|
||||
let dir_b = base.join("engine-b");
|
||||
|
||||
let mut config_a = wasmtime::Config::new();
|
||||
enable_compilation_cache(&mut config_a, "a", Some(dir_a.as_path()))
|
||||
.expect("cache A should succeed");
|
||||
|
||||
let mut config_b = wasmtime::Config::new();
|
||||
enable_compilation_cache(&mut config_b, "b", Some(dir_b.as_path()))
|
||||
.expect("cache B should succeed");
|
||||
|
||||
// Both directories must exist and be distinct.
|
||||
assert!(dir_a.exists());
|
||||
assert!(dir_b.exists());
|
||||
assert_ne!(dir_a, dir_b);
|
||||
}
|
||||
|
||||
/// The WASM runtime (Wasmtime engine) must initialise successfully even
|
||||
/// when no tools directory exists on disk. The engine only configures the
|
||||
/// compiler and epoch ticker — loading modules from a directory is a
|
||||
|
||||
+341
-11
@@ -1,8 +1,8 @@
|
||||
//! Memory hygiene: automatic cleanup of stale workspace documents.
|
||||
//!
|
||||
//! Runs on a configurable cadence and deletes daily log entries older
|
||||
//! than the retention period. Identity files (`IDENTITY.md`, `SOUL.md`,
|
||||
//! etc.) are never touched.
|
||||
//! Runs on a configurable cadence and deletes daily log entries and conversation
|
||||
//! documents older than their respective retention periods. Identity files
|
||||
//! (`IDENTITY.md`, `SOUL.md`, etc.) are never touched.
|
||||
//!
|
||||
//! A global [`AtomicBool`] guard prevents concurrent hygiene passes, which
|
||||
//! avoids TOCTOU races on the state file and Windows file-locking errors
|
||||
@@ -17,8 +17,10 @@
|
||||
//! │ 1. Check cadence (skip if ran recently) │
|
||||
//! │ 2. Save state (claim the cadence window) │
|
||||
//! │ 3. List daily/ documents │
|
||||
//! │ 4. Delete those older than retention_days │
|
||||
//! │ 5. Log summary │
|
||||
//! │ 4. Delete those older than daily_retention │
|
||||
//! │ 5. List conversations/ documents │
|
||||
//! │ 6. Delete those older than conversation_ret │
|
||||
//! │ 7. Log summary │
|
||||
//! └─────────────────────────────────────────────┘
|
||||
//! ```
|
||||
|
||||
@@ -34,13 +36,41 @@ use crate::workspace::Workspace;
|
||||
/// Global guard preventing concurrent hygiene passes.
|
||||
static RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Paths that must never be deleted by hygiene, regardless of age.
|
||||
const IDENTITY_PATHS: &[&str] = &[
|
||||
crate::workspace::document::paths::MEMORY,
|
||||
crate::workspace::document::paths::IDENTITY,
|
||||
crate::workspace::document::paths::SOUL,
|
||||
crate::workspace::document::paths::AGENTS,
|
||||
crate::workspace::document::paths::USER,
|
||||
crate::workspace::document::paths::HEARTBEAT,
|
||||
crate::workspace::document::paths::README,
|
||||
crate::workspace::document::paths::TOOLS,
|
||||
crate::workspace::document::paths::BOOTSTRAP,
|
||||
];
|
||||
|
||||
/// Check if a document path is an identity document that must never be deleted.
|
||||
///
|
||||
/// Performs case-insensitive comparison to handle case-insensitive filesystems
|
||||
/// (Windows, macOS) and prevent accidental deletion of identity docs with
|
||||
/// different casing (e.g., memory.md, MEMORY.MD, Memory.md).
|
||||
fn is_identity_path(path: &str) -> bool {
|
||||
let file_name = path.rsplit('/').next().unwrap_or(path);
|
||||
let file_name_lower = file_name.to_lowercase();
|
||||
IDENTITY_PATHS
|
||||
.iter()
|
||||
.any(|&p| p.to_lowercase() == file_name_lower)
|
||||
}
|
||||
|
||||
/// Configuration for workspace hygiene.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HygieneConfig {
|
||||
/// Whether hygiene is enabled at all.
|
||||
pub enabled: bool,
|
||||
/// Documents in `daily/` older than this many days are deleted.
|
||||
pub retention_days: u32,
|
||||
pub daily_retention_days: u32,
|
||||
/// Documents in `conversations/` older than this many days are deleted.
|
||||
pub conversation_retention_days: u32,
|
||||
/// Minimum hours between hygiene passes.
|
||||
pub cadence_hours: u32,
|
||||
/// Directory to store state file (default: `~/.ironclaw`).
|
||||
@@ -51,7 +81,8 @@ impl Default for HygieneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
retention_days: 30,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 12,
|
||||
state_dir: ironclaw_base_dir(),
|
||||
}
|
||||
@@ -69,6 +100,8 @@ struct HygieneState {
|
||||
pub struct HygieneReport {
|
||||
/// Number of daily log documents deleted.
|
||||
pub daily_logs_deleted: u32,
|
||||
/// Number of conversation documents deleted.
|
||||
pub conversation_docs_deleted: u32,
|
||||
/// Whether the run was skipped (cadence not yet elapsed).
|
||||
pub skipped: bool,
|
||||
}
|
||||
@@ -76,7 +109,7 @@ pub struct HygieneReport {
|
||||
impl HygieneReport {
|
||||
/// True if any cleanup work was done.
|
||||
pub fn had_work(&self) -> bool {
|
||||
self.daily_logs_deleted > 0
|
||||
self.daily_logs_deleted > 0 || self.conversation_docs_deleted > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,21 +169,29 @@ pub async fn run_if_due(workspace: &Workspace, config: &HygieneConfig) -> Hygien
|
||||
save_state(&state_file);
|
||||
|
||||
tracing::info!(
|
||||
retention_days = config.retention_days,
|
||||
daily_retention_days = config.daily_retention_days,
|
||||
conversation_retention_days = config.conversation_retention_days,
|
||||
"memory hygiene: starting cleanup pass"
|
||||
);
|
||||
|
||||
let mut report = HygieneReport::default();
|
||||
|
||||
// Delete old daily logs
|
||||
match cleanup_daily_logs(workspace, config.retention_days).await {
|
||||
match cleanup_daily_logs(workspace, config.daily_retention_days).await {
|
||||
Ok(count) => report.daily_logs_deleted = count,
|
||||
Err(e) => tracing::warn!("memory hygiene: failed to clean daily logs: {e}"),
|
||||
}
|
||||
|
||||
// Delete old conversation documents
|
||||
match cleanup_conversation_docs(workspace, config.conversation_retention_days).await {
|
||||
Ok(count) => report.conversation_docs_deleted = count,
|
||||
Err(e) => tracing::warn!("memory hygiene: failed to clean conversation docs: {e}"),
|
||||
}
|
||||
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
||||
"memory hygiene: cleanup complete"
|
||||
);
|
||||
} else {
|
||||
@@ -183,6 +224,11 @@ async fn cleanup_daily_logs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Never delete identity documents
|
||||
if is_identity_path(&entry.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the document is old enough to delete
|
||||
if let Some(updated_at) = entry.updated_at
|
||||
&& updated_at < cutoff
|
||||
@@ -205,6 +251,50 @@ async fn cleanup_daily_logs(
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Delete conversation documents older than `retention_days`.
|
||||
async fn cleanup_conversation_docs(
|
||||
workspace: &Workspace,
|
||||
retention_days: u32,
|
||||
) -> Result<u32, anyhow::Error> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
let entries = workspace.list("conversations/").await?;
|
||||
|
||||
let mut deleted = 0u32;
|
||||
for entry in entries {
|
||||
if entry.is_directory {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Never delete identity documents
|
||||
if is_identity_path(&entry.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the document is old enough to delete
|
||||
if let Some(updated_at) = entry.updated_at
|
||||
&& updated_at < cutoff
|
||||
{
|
||||
let path = if entry.path.starts_with("conversations/") {
|
||||
entry.path.clone()
|
||||
} else {
|
||||
format!("conversations/{}", entry.path)
|
||||
};
|
||||
|
||||
if let Err(e) = workspace.delete(&path).await {
|
||||
tracing::warn!(
|
||||
path,
|
||||
"memory hygiene: failed to delete conversation doc: {e}"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(path, "memory hygiene: deleted old conversation doc");
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn state_path_dir(state_file: &std::path::Path) -> Option<&std::path::Path> {
|
||||
state_file.parent()
|
||||
}
|
||||
@@ -259,7 +349,8 @@ mod tests {
|
||||
fn default_config_is_reasonable() {
|
||||
let cfg = HygieneConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.retention_days, 30);
|
||||
assert_eq!(cfg.daily_retention_days, 30);
|
||||
assert_eq!(cfg.conversation_retention_days, 7);
|
||||
assert_eq!(cfg.cadence_hours, 12);
|
||||
}
|
||||
|
||||
@@ -274,11 +365,83 @@ mod tests {
|
||||
fn report_had_work_when_deleted() {
|
||||
let report = HygieneReport {
|
||||
daily_logs_deleted: 3,
|
||||
conversation_docs_deleted: 0,
|
||||
skipped: false,
|
||||
};
|
||||
assert!(report.had_work());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_had_work_when_conversation_deleted() {
|
||||
let report = HygieneReport {
|
||||
daily_logs_deleted: 0,
|
||||
conversation_docs_deleted: 2,
|
||||
skipped: false,
|
||||
};
|
||||
assert!(report.had_work());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_identity_path_excludes_sacred_docs() {
|
||||
for name in [
|
||||
"MEMORY.md",
|
||||
"IDENTITY.md",
|
||||
"SOUL.md",
|
||||
"AGENTS.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"README.md",
|
||||
"TOOLS.md",
|
||||
"BOOTSTRAP.md",
|
||||
] {
|
||||
assert!(is_identity_path(name), "{name} should be excluded");
|
||||
assert!(
|
||||
is_identity_path(&format!("conversations/{name}")),
|
||||
"conversations/{name} should be excluded via path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_identity_path_case_insensitive() {
|
||||
// Verify case-insensitive matching for case-insensitive filesystems
|
||||
assert!(
|
||||
is_identity_path("memory.md"),
|
||||
"lowercase memory.md should be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_identity_path("Memory.md"),
|
||||
"mixed case Memory.md should be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_identity_path("MEMORY.MD"),
|
||||
"uppercase MEMORY.MD should be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_identity_path("identity.md"),
|
||||
"lowercase identity.md should be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_identity_path("conversations/soul.md"),
|
||||
"conversations/soul.md should be excluded"
|
||||
);
|
||||
assert!(
|
||||
is_identity_path("conversations/SOUL.MD"),
|
||||
"conversations/SOUL.MD should be excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_identity_path_allows_normal_docs() {
|
||||
for path in [
|
||||
"daily/2024-01-01.md",
|
||||
"conversations/chat-abc.md",
|
||||
"notes.md",
|
||||
] {
|
||||
assert!(!is_identity_path(path), "{path} should not be excluded");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_state_returns_none_for_missing_file() {
|
||||
assert!(load_state(std::path::Path::new("/tmp/nonexistent_hygiene.json")).is_none());
|
||||
@@ -328,6 +491,9 @@ mod tests {
|
||||
fn running_guard_prevents_reentry() {
|
||||
let _lock = RUNNING_TESTS.lock().unwrap();
|
||||
|
||||
// Reset the global flag to ensure a clean state
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
|
||||
// Simulate acquiring the guard
|
||||
assert!(
|
||||
RUNNING
|
||||
@@ -356,4 +522,168 @@ mod tests {
|
||||
);
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Async integration tests (require libsql backend)
|
||||
// ================================================================
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod async_tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Helper to create a test database with migrations.
|
||||
async fn create_test_db() -> (Arc<dyn crate::db::Database>, tempfile::TempDir) {
|
||||
use crate::db::libsql::LibSqlBackend;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||
let db_path = temp_dir.path().join("test_hygiene.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.expect("LibSqlBackend::new_local");
|
||||
backend.run_migrations().await.expect("run_migrations");
|
||||
let db: Arc<dyn Database> = Arc::new(backend);
|
||||
(db, temp_dir)
|
||||
}
|
||||
|
||||
/// Helper to create a workspace from a test database.
|
||||
fn create_workspace(db: &Arc<dyn Database>) -> Arc<Workspace> {
|
||||
Arc::new(Workspace::new_with_db("default", db.clone()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_daily_logs_preserves_identity_documents() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
// Write several regular documents (non-identity)
|
||||
ws.write("daily/2024-01-15.md", "Old log")
|
||||
.await
|
||||
.expect("write log 1");
|
||||
ws.write("daily/2024-01-20.md", "Another log")
|
||||
.await
|
||||
.expect("write log 2");
|
||||
|
||||
// Write an identity document
|
||||
ws.write("MEMORY.md", "Long-term curated memory")
|
||||
.await
|
||||
.expect("write identity");
|
||||
|
||||
// List before cleanup
|
||||
let before = ws.list("daily/").await.expect("list before");
|
||||
let daily_count_before = before.iter().filter(|e| !e.is_directory).count();
|
||||
assert!(daily_count_before >= 2, "should have at least 2 daily logs");
|
||||
|
||||
// Run cleanup with 0-day retention (deletes everything old)
|
||||
// This tests that even with aggressive cleanup, identity docs survive
|
||||
let deleted = cleanup_daily_logs(&ws, 0)
|
||||
.await
|
||||
.expect("cleanup_daily_logs");
|
||||
|
||||
// Should have deleted some documents (the daily logs)
|
||||
assert!(deleted > 0, "should have deleted old daily documents");
|
||||
|
||||
// Verify identity doc still exists
|
||||
let identity = db
|
||||
.get_document_by_path("default", None, "MEMORY.md")
|
||||
.await
|
||||
.expect("get identity doc");
|
||||
assert_eq!(identity.path, "MEMORY.md");
|
||||
assert_eq!(identity.content, "Long-term curated memory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_conversation_docs_handles_empty_directory() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
// Run cleanup on an empty directory (conversations/ doesn't exist)
|
||||
let deleted = cleanup_conversation_docs(&ws, 7)
|
||||
.await
|
||||
.expect("cleanup_conversation_docs");
|
||||
|
||||
// Should delete 0 (nothing to delete)
|
||||
assert_eq!(deleted, 0, "should delete 0 from empty directory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_respects_cadence_prevents_concurrent_runs() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
let config = HygieneConfig {
|
||||
enabled: true,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 12,
|
||||
state_dir: _tmp.path().to_path_buf(),
|
||||
};
|
||||
|
||||
// First run should succeed
|
||||
let report1 = run_if_due(&ws, &config).await;
|
||||
assert!(!report1.skipped, "first run should not be skipped");
|
||||
|
||||
// Second run immediately should be skipped (cadence not elapsed)
|
||||
let report2 = run_if_due(&ws, &config).await;
|
||||
assert!(report2.skipped, "second run should be skipped by cadence");
|
||||
|
||||
// Report structure should be correct
|
||||
assert_eq!(
|
||||
report1.daily_logs_deleted + report1.conversation_docs_deleted,
|
||||
0,
|
||||
"first run should have clean counts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_reports_deletion_counts_correctly() {
|
||||
let (db, _tmp) = create_test_db().await;
|
||||
let ws = create_workspace(&db);
|
||||
|
||||
// Write some documents
|
||||
ws.write("daily/log1.md", "content 1")
|
||||
.await
|
||||
.expect("write doc 1");
|
||||
ws.write("daily/log2.md", "content 2")
|
||||
.await
|
||||
.expect("write doc 2");
|
||||
ws.write("conversations/chat1.md", "content 3")
|
||||
.await
|
||||
.expect("write doc 3");
|
||||
|
||||
// Run with 0-day retention to delete everything non-identity
|
||||
let deleted_daily = cleanup_daily_logs(&ws, 0).await.expect("cleanup daily");
|
||||
let deleted_conv = cleanup_conversation_docs(&ws, 0)
|
||||
.await
|
||||
.expect("cleanup conversations");
|
||||
|
||||
// Both should report deletions
|
||||
assert!(deleted_daily > 0, "should report deleted daily logs");
|
||||
assert_eq!(deleted_conv, 1, "should report 1 deleted conversation doc");
|
||||
|
||||
// Create a HygieneReport and verify aggregation works
|
||||
let report = HygieneReport {
|
||||
daily_logs_deleted: deleted_daily,
|
||||
conversation_docs_deleted: deleted_conv,
|
||||
skipped: false,
|
||||
};
|
||||
|
||||
// Verify HygieneReport structure
|
||||
assert!(!report.skipped, "should not be skipped");
|
||||
assert!(report.had_work(), "report should indicate work was done");
|
||||
assert!(
|
||||
report.daily_logs_deleted > 0 || report.conversation_docs_deleted > 0,
|
||||
"report should have at least one deletion count > 0"
|
||||
);
|
||||
|
||||
// Verify had_work() correctly combines both counts
|
||||
let no_work = HygieneReport {
|
||||
daily_logs_deleted: 0,
|
||||
conversation_docs_deleted: 0,
|
||||
skipped: false,
|
||||
};
|
||||
assert!(!no_work.had_work(), "empty report should indicate no work");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,67 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_result_with_path(chunk_id: Uuid, doc_id: Uuid, path: &str, rank: u32) -> RankedResult {
|
||||
RankedResult {
|
||||
chunk_id,
|
||||
document_id: doc_id,
|
||||
document_path: path.to_string(),
|
||||
content: format!("content for chunk {}", chunk_id),
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rrf_propagates_document_path() {
|
||||
// Regression test: search results must carry the source document's
|
||||
// file path, not the document UUID. See PR #503 / issue #481.
|
||||
let config = SearchConfig::default().with_limit(10);
|
||||
|
||||
let doc_a = Uuid::new_v4();
|
||||
let doc_b = Uuid::new_v4();
|
||||
let chunk1 = Uuid::new_v4();
|
||||
let chunk2 = Uuid::new_v4();
|
||||
let chunk3 = Uuid::new_v4();
|
||||
|
||||
let fts_results = vec![
|
||||
make_result_with_path(chunk1, doc_a, "notes/todo.md", 1),
|
||||
make_result_with_path(chunk2, doc_b, "journal/2024-01-15.md", 2),
|
||||
];
|
||||
let vector_results = vec![
|
||||
make_result_with_path(chunk1, doc_a, "notes/todo.md", 1),
|
||||
make_result_with_path(chunk3, doc_b, "journal/2024-01-15.md", 2),
|
||||
];
|
||||
|
||||
let results = reciprocal_rank_fusion(fts_results, vector_results, &config);
|
||||
|
||||
for result in &results {
|
||||
// The path must be a real file path, never a UUID string
|
||||
assert!(
|
||||
Uuid::parse_str(&result.document_path).is_err(),
|
||||
"document_path looks like a UUID ('{}'), expected a file path",
|
||||
result.document_path
|
||||
);
|
||||
}
|
||||
|
||||
// Verify exact paths are preserved
|
||||
let paths: Vec<&str> = results.iter().map(|r| r.document_path.as_str()).collect();
|
||||
assert!(
|
||||
paths.contains(&"notes/todo.md"),
|
||||
"missing notes/todo.md in {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"journal/2024-01-15.md"),
|
||||
"missing journal/2024-01-15.md in {:?}",
|
||||
paths
|
||||
);
|
||||
|
||||
// Hybrid match (chunk1) should preserve the correct path
|
||||
let hybrid = results.iter().find(|r| r.chunk_id == chunk1).unwrap();
|
||||
assert_eq!(hybrid.document_path, "notes/todo.md");
|
||||
assert!(hybrid.is_hybrid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rrf_single_method() {
|
||||
let config = SearchConfig::default().with_limit(10);
|
||||
|
||||
@@ -203,6 +203,7 @@ mod tests {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
images: vec![],
|
||||
};
|
||||
let fired = engine.check_event_triggers(&matching_msg).await;
|
||||
assert!(
|
||||
@@ -223,6 +224,7 @@ mod tests {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
images: vec![],
|
||||
};
|
||||
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
|
||||
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
|
||||
@@ -286,6 +288,7 @@ mod tests {
|
||||
thread_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
images: vec![],
|
||||
};
|
||||
let fired1 = engine.check_event_triggers(&msg).await;
|
||||
assert!(fired1 >= 1, "First fire should work");
|
||||
@@ -348,7 +351,8 @@ mod tests {
|
||||
|
||||
let hygiene_config = HygieneConfig {
|
||||
enabled: false,
|
||||
retention_days: 30,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 24,
|
||||
state_dir: _tmp.path().to_path_buf(),
|
||||
};
|
||||
@@ -399,7 +403,8 @@ mod tests {
|
||||
|
||||
let hygiene_config = HygieneConfig {
|
||||
enabled: false,
|
||||
retention_days: 30,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 24,
|
||||
state_dir: _tmp.path().to_path_buf(),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ use ironclaw::{
|
||||
agent::HeartbeatRunner,
|
||||
config::Config,
|
||||
history::Store,
|
||||
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
||||
llm::{create_llm_provider, create_session_manager},
|
||||
safety::SafetyLayer,
|
||||
workspace::Workspace,
|
||||
};
|
||||
@@ -84,11 +84,7 @@ async fn test_heartbeat_end_to_end() {
|
||||
}
|
||||
|
||||
// 5. Create LLM provider
|
||||
let session = create_session_manager(SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
})
|
||||
.await;
|
||||
let session = create_session_manager(config.llm.session.clone()).await;
|
||||
let llm = create_llm_provider(&config.llm, session).expect("Failed to create LLM provider");
|
||||
println!("[5/6] LLM provider created (model: {})", llm.model_name());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user