mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a320a64086 | ||
|
|
13813cbb18 | ||
|
|
833738bc85 | ||
|
|
5c2ba44f12 | ||
|
|
f4d290f5ed | ||
|
|
13e000dc20 | ||
|
|
ce5961b1ec | ||
|
|
ffb9978ec6 | ||
|
|
469a252051 | ||
|
|
d195222124 | ||
|
|
5869a9cc62 | ||
|
|
1caed5a163 | ||
|
|
e1d364c636 | ||
|
|
7806273aa6 | ||
|
|
26d274ac79 | ||
|
|
b425213c53 | ||
|
|
37bba72397 | ||
|
|
2df9602d56 | ||
|
|
06c84a5c77 | ||
|
|
04c5c3fe9f | ||
|
|
a516e92156 |
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
targets: wasm32-wasip2
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -52,11 +53,21 @@ jobs:
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install cargo-component
|
||||
run: |
|
||||
if ! command -v cargo-component >/dev/null 2>&1; then
|
||||
cargo install cargo-component --locked
|
||||
fi
|
||||
|
||||
- name: Build WASM channels (for integration tests)
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
|
||||
- name: Run database migrations
|
||||
if: matrix.has_postgres
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in migrations/V*.sql; do
|
||||
readarray -t migration_files < <(printf '%s\n' migrations/V*.sql | sort -V)
|
||||
for f in "${migration_files[@]}"; do
|
||||
echo "Applying $f..."
|
||||
psql -v ON_ERROR_STOP=1 -f "$f"
|
||||
done
|
||||
@@ -92,6 +103,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
targets: wasm32-wasip2
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -100,12 +112,21 @@ jobs:
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install cargo-component
|
||||
run: |
|
||||
if ! command -v cargo-component >/dev/null 2>&1; then
|
||||
cargo install cargo-component --locked
|
||||
fi
|
||||
|
||||
- name: Build WASM channels
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
|
||||
- name: Set up coverage instrumentation
|
||||
run: |
|
||||
# Append ALL env vars from show-env (including CARGO_ENCODED_RUSTFLAGS,
|
||||
# CARGO_INCREMENTAL, LLVM_PROFILE_FILE, etc.) so the build step
|
||||
# compiles an instrumented binary regardless of cargo-llvm-cov version.
|
||||
cargo llvm-cov show-env >> "$GITHUB_ENV"
|
||||
# show-env outputs shell-quoted values (KEY='value') but GITHUB_ENV
|
||||
# expects unquoted KEY=value. Strip only the wrapping single quotes
|
||||
# from KEY='value' lines without altering any internal characters.
|
||||
cargo llvm-cov show-env | sed -E "s/^([A-Za-z_][A-Za-z0-9_]*)='(.*)'$/\1=\2/" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Clean coverage workspace
|
||||
run: cargo llvm-cov clean --workspace
|
||||
|
||||
@@ -9,8 +9,9 @@ on:
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Browser E2E
|
||||
# ── Step 1: compile once ──────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build ironclaw (libsql)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -25,9 +26,44 @@ jobs:
|
||||
~/.cargo/registry
|
||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build ironclaw (libsql)
|
||||
- name: Build
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ironclaw-e2e-binary
|
||||
path: target/debug/ironclaw
|
||||
retention-days: 1
|
||||
|
||||
# ── Step 2: run test slices in parallel ───────────────────────────────────
|
||||
test:
|
||||
name: E2E (${{ matrix.group }})
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: core
|
||||
files: "tests/e2e/scenarios/test_connection.py tests/e2e/scenarios/test_chat.py tests/e2e/scenarios/test_sse_reconnect.py tests/e2e/scenarios/test_html_injection.py"
|
||||
- group: features
|
||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
|
||||
- group: extensions
|
||||
files: "tests/e2e/scenarios/test_extensions.py"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ironclaw-e2e-binary
|
||||
path: target/debug/
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x target/debug/ironclaw
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
@@ -38,13 +74,26 @@ jobs:
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v -x --timeout=120
|
||||
- name: Run E2E tests (${{ matrix.group }})
|
||||
run: pytest ${{ matrix.files }} -v --timeout=120
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
name: e2e-screenshots-${{ matrix.group }}
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── Roll-up for branch protection ────────────────────────────────────────
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [test]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
||||
echo "One or more E2E jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -26,9 +26,14 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
targets: wasm32-wasip2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.name }}
|
||||
- name: Install cargo-component
|
||||
run: cargo install cargo-component --locked || true
|
||||
- name: Build WASM channels (for integration tests)
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.flags }} -- --nocapture
|
||||
|
||||
@@ -46,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
|
||||
@@ -76,15 +107,34 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
|
||||
version-check:
|
||||
name: Version Bump Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check version bumps for changed extensions
|
||||
env:
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: ./scripts/check-version-bumps.sh
|
||||
|
||||
# Roll-up job for branch protection
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build]
|
||||
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
|
||||
# version-check only runs on PRs, so skip/success are both acceptable
|
||||
if [[ "${{ needs.version-check.result }}" == "failure" ]]; then
|
||||
echo "Version bump check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -7,6 +7,43 @@ 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
|
||||
|
||||
- *(e2e)* extensions tab tests, CI parallelization, and 3 production bug fixes ([#584](https://github.com/nearai/ironclaw/pull/584))
|
||||
- WASM extension versioning with WIT compat checks ([#592](https://github.com/nearai/ironclaw/pull/592))
|
||||
- Add HMAC-SHA256 webhook signature validation for Slack ([#588](https://github.com/nearai/ironclaw/pull/588))
|
||||
- restart ([#531](https://github.com/nearai/ironclaw/pull/531))
|
||||
- merge http/web_fetch tools, add tool output stash for large responses ([#578](https://github.com/nearai/ironclaw/pull/578))
|
||||
- integrate 13-dimension complexity scorer into smart routing ([#529](https://github.com/nearai/ironclaw/pull/529))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(llm)* fix reasoning model response parsing bugs ([#564](https://github.com/nearai/ironclaw/pull/564)) ([#580](https://github.com/nearai/ironclaw/pull/580))
|
||||
- *(ci)* fix three coverage workflow failures ([#597](https://github.com/nearai/ironclaw/pull/597))
|
||||
- Telegram channel accepts group messages from all users if owner_… ([#590](https://github.com/nearai/ironclaw/pull/590))
|
||||
- *(ci)* anchor coverage/ gitignore rule to repo root ([#591](https://github.com/nearai/ironclaw/pull/591))
|
||||
- *(security)* use OsRng for all security-critical key and token generation ([#519](https://github.com/nearai/ironclaw/pull/519))
|
||||
- prevent concurrent memory hygiene passes and Windows file lock errors ([#535](https://github.com/nearai/ironclaw/pull/535))
|
||||
- sort tool_definitions() for deterministic LLM tool ordering ([#582](https://github.com/nearai/ironclaw/pull/582))
|
||||
- *(ci)* persist all cargo-llvm-cov env vars for E2E coverage ([#559](https://github.com/nearai/ironclaw/pull/559))
|
||||
|
||||
### Other
|
||||
|
||||
- *(llm)* complete response cache — set_model invalidation, stats logging, sync mutex ([#290](https://github.com/nearai/ironclaw/pull/290))
|
||||
- add 29 E2E trace tests for issues #571-575 ([#593](https://github.com/nearai/ironclaw/pull/593))
|
||||
- add 26 tests for multi-thread safety, db CRUD, concurrency, errors ([#442](https://github.com/nearai/ironclaw/pull/442))
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#560](https://github.com/nearai/ironclaw/pull/560))
|
||||
- add WIT compatibility tests for WASM extensions ([#586](https://github.com/nearai/ironclaw/pull/586))
|
||||
- Trajectory benchmarks and e2e trace test rig ([#553](https://github.com/nearai/ironclaw/pull/553))
|
||||
|
||||
## [0.15.0](https://github.com/nearai/ironclaw/compare/v0.14.0...v0.15.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+24
-1
@@ -2828,7 +2828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.15.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -2880,6 +2880,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yml",
|
||||
@@ -2901,6 +2902,7 @@ dependencies = [
|
||||
"tower-http 0.6.8",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -6228,6 +6230,27 @@ dependencies = [
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-test"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051"
|
||||
dependencies = [
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
"tracing-test-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-test-macro"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
|
||||
+5
-1
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.15.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"
|
||||
@@ -106,6 +106,9 @@ serde_yml = "0.0.12"
|
||||
dirs = "6"
|
||||
fs4 = "0.6"
|
||||
|
||||
# Semantic versioning
|
||||
semver = "1"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
@@ -171,6 +174,7 @@ zbus = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tracing-test = "0.2"
|
||||
tokio-tungstenite = "0.26"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
pretty_assertions = "1"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
|
||||
@@ -1032,11 +1032,14 @@ fn handle_message(message: TelegramMessage) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if is_private {
|
||||
// No owner_id: apply dm_policy for private chats
|
||||
} else {
|
||||
// No owner_id: apply authorization based on dm_policy and allow_from
|
||||
// This applies to both private and group chats when owner_id is null
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
// For private chats with non-open policy, check allowlist
|
||||
// For group chats with non-open policy, also check allowlist
|
||||
if dm_policy != "open" {
|
||||
// Build effective allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
@@ -1054,8 +1057,8 @@ fn handle_message(message: TelegramMessage) {
|
||||
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
|
||||
|
||||
if !is_allowed {
|
||||
if dm_policy == "pairing" {
|
||||
// Upsert pairing request and send reply
|
||||
if is_private && dm_policy == "pairing" {
|
||||
// Upsert pairing request and send reply (only for private chats)
|
||||
let meta = serde_json::json!({
|
||||
"chat_id": message.chat.id,
|
||||
"user_id": from.id,
|
||||
@@ -1083,6 +1086,15 @@ fn handle_message(message: TelegramMessage) {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if !is_private {
|
||||
// For group chats with non-open dm_policy, just log and drop
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from unauthorized user {} in group chat",
|
||||
from.id
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"type": "channel",
|
||||
"name": "whatsapp",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Add wit_version column to wasm_tools for WIT interface version tracking
|
||||
ALTER TABLE wasm_tools ADD COLUMN IF NOT EXISTS wit_version TEXT NOT NULL DEFAULT '0.1.0';
|
||||
|
||||
-- Create wasm_channels table for DB-stored channel extensions
|
||||
CREATE TABLE IF NOT EXISTS wasm_channels (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
wit_version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
wasm_binary BYTEA NOT NULL,
|
||||
binary_hash BYTEA NOT NULL,
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT unique_wasm_channel UNIQUE (user_id, name)
|
||||
);
|
||||
+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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Discord Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent in Discord",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Slack Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent in Slack",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent through a Telegram bot",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "WhatsApp Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Talk to your agent through WhatsApp",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "GitHub",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"keywords": [
|
||||
"git",
|
||||
@@ -19,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Gmail",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Read, send, and manage Gmail messages and threads",
|
||||
"keywords": [
|
||||
"email",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Google Calendar",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create, read, update, and delete Google Calendar events",
|
||||
"keywords": [
|
||||
"calendar",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Google Docs",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create and edit Google Docs documents",
|
||||
"keywords": [
|
||||
"documents",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Google Drive",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||
"keywords": [
|
||||
"storage",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Google Sheets",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Read and write Google Sheets spreadsheet data",
|
||||
"keywords": [
|
||||
"spreadsheets",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Google Slides",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Create and edit Google Slides presentations",
|
||||
"keywords": [
|
||||
"presentations",
|
||||
@@ -17,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Slack Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -17,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Telegram Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Your agent uses your Telegram account to read and send messages",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
@@ -18,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": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.2.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": [
|
||||
"search",
|
||||
@@ -18,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": {
|
||||
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CI script: check that version bumps accompany WIT or extension source changes.
|
||||
# Exit 0 if all checks pass, exit 1 if any version wasn't bumped.
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# --- Skip mechanism -----------------------------------------------------------
|
||||
|
||||
if [[ "${PR_LABELS:-}" == *"skip-version-check"* ]]; then
|
||||
echo "skip-version-check label detected — skipping all version checks."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check commit messages for [skip-version-check]
|
||||
if git log "origin/${GITHUB_BASE_REF:-main}...HEAD" --pretty=format:"%s %b" 2>/dev/null \
|
||||
| grep -qF '[skip-version-check]'; then
|
||||
echo "[skip-version-check] found in commit message — skipping all version checks."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Determine base branch and changed files ----------------------------------
|
||||
|
||||
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
|
||||
# Ensure the base branch ref is available
|
||||
if ! git rev-parse "origin/${BASE_BRANCH}" >/dev/null 2>&1; then
|
||||
echo "Fetching origin/${BASE_BRANCH}..."
|
||||
git fetch origin "$BASE_BRANCH" --depth=1
|
||||
fi
|
||||
|
||||
CHANGED_FILES=$(git diff --name-only "origin/${BASE_BRANCH}...HEAD")
|
||||
|
||||
if [[ -z "$CHANGED_FILES" ]]; then
|
||||
echo "No changed files detected. Nothing to check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Helper functions ---------------------------------------------------------
|
||||
|
||||
# Extract the version from a WIT package line like: package near:[email protected];
|
||||
extract_wit_version() {
|
||||
local file="$1"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
sed -n 's/^[[:space:]]*package[[:space:]]\+[^@]*@\([0-9][0-9.]*[0-9]\)[[:space:]]*;.*/\1/p' "$file" \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
# Extract version from the base branch copy of a file
|
||||
extract_wit_version_base() {
|
||||
local file="$1"
|
||||
git show "origin/${BASE_BRANCH}:${file}" 2>/dev/null \
|
||||
| sed -n 's/^[[:space:]]*package[[:space:]]\+[^@]*@\([0-9][0-9.]*[0-9]\)[[:space:]]*;.*/\1/p' \
|
||||
| head -n1 || true
|
||||
}
|
||||
|
||||
# Extract a Rust string constant value: pub const NAME: &str = "value";
|
||||
extract_rust_const() {
|
||||
local file="$1"
|
||||
local const_name="$2"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
sed -n "s/^.*${const_name}[[:space:]]*:[[:space:]]*&str[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$file" \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
# Extract JSON "version" field using jq
|
||||
extract_json_version() {
|
||||
local file="$1"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
jq -r '.version // empty' "$file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Extract JSON "version" from the base branch copy of a file
|
||||
extract_json_version_base() {
|
||||
local file="$1"
|
||||
git show "origin/${BASE_BRANCH}:${file}" 2>/dev/null | jq -r '.version // empty' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Return 0 if $1 (new) is strictly greater than $2 (old) via sort -V, or old is empty.
|
||||
version_was_bumped() {
|
||||
local new="$1"
|
||||
local old="$2"
|
||||
if [[ -z "$old" ]]; then
|
||||
# No prior version — treat as new, no bump required
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$new" ]]; then
|
||||
# Version was removed — that's a problem
|
||||
return 1
|
||||
fi
|
||||
if [[ "$new" == "$old" ]]; then
|
||||
return 1
|
||||
fi
|
||||
# Check new > old via sort -V
|
||||
local highest
|
||||
highest=$(printf '%s\n%s\n' "$new" "$old" | sort -V | tail -n1)
|
||||
[[ "$highest" == "$new" ]]
|
||||
}
|
||||
|
||||
# --- 1. WIT changes ----------------------------------------------------------
|
||||
|
||||
WIT_TOOL_CHANGED=false
|
||||
WIT_CHANNEL_CHANGED=false
|
||||
|
||||
if echo "$CHANGED_FILES" | grep -qx 'wit/tool\.wit'; then
|
||||
WIT_TOOL_CHANGED=true
|
||||
fi
|
||||
if echo "$CHANGED_FILES" | grep -qx 'wit/channel\.wit'; then
|
||||
WIT_CHANNEL_CHANGED=true
|
||||
fi
|
||||
|
||||
if $WIT_TOOL_CHANGED; then
|
||||
echo ""
|
||||
echo "=== wit/tool.wit changed ==="
|
||||
|
||||
NEW_VER=$(extract_wit_version "wit/tool.wit")
|
||||
OLD_VER=$(extract_wit_version_base "wit/tool.wit")
|
||||
echo " WIT package version: ${OLD_VER:-<none>} -> ${NEW_VER:-<missing>}"
|
||||
|
||||
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
|
||||
echo " ERROR: wit/tool.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>})."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo " OK: WIT package version bumped."
|
||||
fi
|
||||
|
||||
# Check WIT_TOOL_VERSION constant matches
|
||||
CONST_VER=$(extract_rust_const "src/tools/wasm/mod.rs" "WIT_TOOL_VERSION")
|
||||
if [[ -n "$NEW_VER" && "$CONST_VER" != "$NEW_VER" ]]; then
|
||||
echo " ERROR: WIT_TOOL_VERSION in src/tools/wasm/mod.rs is '${CONST_VER}' but wit/tool.wit has '${NEW_VER}'. They must match."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [[ -n "$NEW_VER" ]]; then
|
||||
echo " OK: WIT_TOOL_VERSION matches wit/tool.wit."
|
||||
fi
|
||||
fi
|
||||
|
||||
if $WIT_CHANNEL_CHANGED; then
|
||||
echo ""
|
||||
echo "=== wit/channel.wit changed ==="
|
||||
|
||||
NEW_VER=$(extract_wit_version "wit/channel.wit")
|
||||
OLD_VER=$(extract_wit_version_base "wit/channel.wit")
|
||||
echo " WIT package version: ${OLD_VER:-<none>} -> ${NEW_VER:-<missing>}"
|
||||
|
||||
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
|
||||
echo " ERROR: wit/channel.wit package version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>})."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo " OK: WIT package version bumped."
|
||||
fi
|
||||
|
||||
# Check WIT_CHANNEL_VERSION constant matches
|
||||
CONST_VER=$(extract_rust_const "src/tools/wasm/mod.rs" "WIT_CHANNEL_VERSION")
|
||||
if [[ -n "$NEW_VER" && "$CONST_VER" != "$NEW_VER" ]]; then
|
||||
echo " ERROR: WIT_CHANNEL_VERSION in src/tools/wasm/mod.rs is '${CONST_VER}' but wit/channel.wit has '${NEW_VER}'. They must match."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [[ -n "$NEW_VER" ]]; then
|
||||
echo " OK: WIT_CHANNEL_VERSION matches wit/channel.wit."
|
||||
fi
|
||||
fi
|
||||
|
||||
if $WIT_TOOL_CHANGED || $WIT_CHANNEL_CHANGED; then
|
||||
echo ""
|
||||
echo " WARNING: WIT interface changed. All published registry extensions should bump their versions for compatibility."
|
||||
fi
|
||||
|
||||
# --- 2. Tool source changes ---------------------------------------------------
|
||||
|
||||
TOOL_NAMES=$(echo "$CHANGED_FILES" | sed -n 's|^tools-src/\([^/]*\)/.*|\1|p' | sort -u)
|
||||
|
||||
if [[ -n "$TOOL_NAMES" ]]; then
|
||||
echo ""
|
||||
echo "=== Tool source changes ==="
|
||||
fi
|
||||
|
||||
for tool in $TOOL_NAMES; do
|
||||
REGISTRY_FILE="registry/tools/${tool}.json"
|
||||
echo ""
|
||||
echo " --- tools-src/${tool}/ changed ---"
|
||||
|
||||
if [[ ! -f "$REGISTRY_FILE" ]]; then
|
||||
echo " SKIP: ${REGISTRY_FILE} does not exist yet (new extension?)."
|
||||
continue
|
||||
fi
|
||||
|
||||
NEW_VER=$(extract_json_version "$REGISTRY_FILE")
|
||||
OLD_VER=$(extract_json_version_base "$REGISTRY_FILE")
|
||||
|
||||
echo " Registry version: ${OLD_VER:-<none>} -> ${NEW_VER:-<missing>}"
|
||||
|
||||
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
|
||||
echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>}). Bump the version when changing tools-src/${tool}/."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo " OK: version bumped."
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 3. Channel source changes ------------------------------------------------
|
||||
|
||||
CHANNEL_NAMES=$(echo "$CHANGED_FILES" | sed -n 's|^channels-src/\([^/]*\)/.*|\1|p' | sort -u)
|
||||
|
||||
if [[ -n "$CHANNEL_NAMES" ]]; then
|
||||
echo ""
|
||||
echo "=== Channel source changes ==="
|
||||
fi
|
||||
|
||||
for channel in $CHANNEL_NAMES; do
|
||||
REGISTRY_FILE="registry/channels/${channel}.json"
|
||||
echo ""
|
||||
echo " --- channels-src/${channel}/ changed ---"
|
||||
|
||||
if [[ ! -f "$REGISTRY_FILE" ]]; then
|
||||
echo " SKIP: ${REGISTRY_FILE} does not exist yet (new extension?)."
|
||||
continue
|
||||
fi
|
||||
|
||||
NEW_VER=$(extract_json_version "$REGISTRY_FILE")
|
||||
OLD_VER=$(extract_json_version_base "$REGISTRY_FILE")
|
||||
|
||||
echo " Registry version: ${OLD_VER:-<none>} -> ${NEW_VER:-<missing>}"
|
||||
|
||||
if ! version_was_bumped "${NEW_VER}" "${OLD_VER}"; then
|
||||
echo " ERROR: ${REGISTRY_FILE} version was not bumped (${OLD_VER} -> ${NEW_VER:-<missing>}). Bump the version when changing channels-src/${channel}/."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo " OK: version bumped."
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Summary ------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo "FAILED: ${ERRORS} version check(s) did not pass. See errors above."
|
||||
exit 1
|
||||
else
|
||||
echo "All version checks passed."
|
||||
exit 0
|
||||
fi
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ pub enum WasmChannelError {
|
||||
|
||||
#[error("HTTP request error: {0}")]
|
||||
HttpRequest(String),
|
||||
|
||||
#[error("WIT version mismatch: {0}")]
|
||||
IncompatibleWitVersion(String),
|
||||
}
|
||||
|
||||
impl From<crate::tools::wasm::WasmError> for WasmChannelError {
|
||||
|
||||
@@ -90,6 +90,14 @@ impl WasmChannelLoader {
|
||||
"Parsed capabilities file"
|
||||
);
|
||||
|
||||
// Check WIT version compatibility
|
||||
crate::tools::wasm::loader::check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_CHANNEL_VERSION,
|
||||
)
|
||||
.map_err(|e| WasmChannelError::IncompatibleWitVersion(e.to_string()))?;
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
|
||||
// Debug: log resulting capabilities
|
||||
|
||||
@@ -87,6 +87,8 @@ mod router;
|
||||
mod runtime;
|
||||
mod schema;
|
||||
pub(crate) mod signature;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod storage;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ use crate::tools::wasm::{CapabilitiesFile as ToolCapabilitiesFile, RateLimitSche
|
||||
/// Root schema for a channel capabilities JSON file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ChannelCapabilitiesFile {
|
||||
/// Extension version (semver).
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
|
||||
/// WIT interface version this channel was compiled against (semver).
|
||||
#[serde(default)]
|
||||
pub wit_version: Option<String>,
|
||||
|
||||
/// File type, must be "channel".
|
||||
#[serde(default = "default_type")]
|
||||
pub r#type: String,
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
//! WASM channel binary storage with integrity verification.
|
||||
//!
|
||||
//! Stores compiled WASM channels in the database with BLAKE3 hash verification.
|
||||
//! Mirrors the pattern in `crate::tools::wasm::storage` but without capabilities table.
|
||||
//!
|
||||
//! # Storage Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! WASM bytes ──► BLAKE3 hash ──► Store in database
|
||||
//! │ (binary + hash)
|
||||
//! │
|
||||
//! └──► Later: Load ──► Verify hash ──► Return bytes
|
||||
//! ```
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
#[cfg(feature = "postgres")]
|
||||
use deadpool_postgres::Pool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::wasm::storage::{compute_binary_hash, verify_binary_integrity};
|
||||
|
||||
/// A stored WASM channel (metadata only, no binary).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredWasmChannel {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub wit_version: String,
|
||||
pub description: String,
|
||||
pub capabilities_json: String,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Full channel data including binary.
|
||||
#[derive(Debug)]
|
||||
pub struct StoredWasmChannelWithBinary {
|
||||
pub channel: StoredWasmChannel,
|
||||
pub wasm_binary: Vec<u8>,
|
||||
pub binary_hash: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parameters for storing a new WASM channel.
|
||||
pub struct StoreChannelParams {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub wit_version: String,
|
||||
pub description: String,
|
||||
pub wasm_binary: Vec<u8>,
|
||||
pub capabilities_json: String,
|
||||
}
|
||||
|
||||
/// Error from WASM channel storage operations.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum WasmChannelStoreError {
|
||||
#[error("Channel not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Binary integrity check failed: hash mismatch")]
|
||||
IntegrityCheckFailed,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
}
|
||||
|
||||
/// Trait for WASM channel storage.
|
||||
#[async_trait]
|
||||
pub trait WasmChannelStore: Send + Sync {
|
||||
/// Store a new WASM channel.
|
||||
async fn store(
|
||||
&self,
|
||||
params: StoreChannelParams,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError>;
|
||||
|
||||
/// Get channel metadata (without binary).
|
||||
async fn get(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError>;
|
||||
|
||||
/// Get channel with binary (verifies integrity).
|
||||
async fn get_with_binary(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannelWithBinary, WasmChannelStoreError>;
|
||||
|
||||
/// List all channels for a user.
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmChannel>, WasmChannelStoreError>;
|
||||
|
||||
/// Delete a channel.
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, WasmChannelStoreError>;
|
||||
}
|
||||
|
||||
// ==================== PostgreSQL implementation ====================
|
||||
|
||||
/// PostgreSQL implementation of WasmChannelStore.
|
||||
#[cfg(feature = "postgres")]
|
||||
pub struct PostgresWasmChannelStore {
|
||||
pool: Pool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
impl PostgresWasmChannelStore {
|
||||
pub fn new(pool: Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
#[async_trait]
|
||||
impl WasmChannelStore for PostgresWasmChannelStore {
|
||||
async fn store(
|
||||
&self,
|
||||
params: StoreChannelParams,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let mut client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let binary_hash = compute_binary_hash(¶ms.wasm_binary);
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
// Wrap delete + insert in a transaction for atomicity
|
||||
let tx = client
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
// Delete any existing version for this (user_id, name) — upgrade-in-place
|
||||
tx.execute(
|
||||
"DELETE FROM wasm_channels WHERE user_id = $1 AND name = $2",
|
||||
&[¶ms.user_id, ¶ms.name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let row = tx
|
||||
.query_one(
|
||||
r#"
|
||||
INSERT INTO wasm_channels (
|
||||
id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'active', $10, $10)
|
||||
RETURNING id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
"#,
|
||||
&[
|
||||
&id,
|
||||
¶ms.user_id,
|
||||
¶ms.name,
|
||||
¶ms.version,
|
||||
¶ms.wit_version,
|
||||
¶ms.description,
|
||||
¶ms.wasm_binary,
|
||||
&binary_hash,
|
||||
¶ms.capabilities_json,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let channel = pg_row_to_channel(&row)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = $1 AND name = $2
|
||||
"#,
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => pg_row_to_channel(&r),
|
||||
None => Err(WasmChannelStoreError::NotFound(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_with_binary(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannelWithBinary, WasmChannelStoreError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let row = client
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
wasm_binary, binary_hash,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = $1 AND name = $2
|
||||
"#,
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let wasm_binary: Vec<u8> = r.get("wasm_binary");
|
||||
let binary_hash: Vec<u8> = r.get("binary_hash");
|
||||
|
||||
if !verify_binary_integrity(&wasm_binary, &binary_hash) {
|
||||
tracing::error!(
|
||||
user_id = user_id,
|
||||
name = name,
|
||||
"WASM channel binary integrity check failed"
|
||||
);
|
||||
return Err(WasmChannelStoreError::IntegrityCheckFailed);
|
||||
}
|
||||
|
||||
let channel = StoredWasmChannel {
|
||||
id: r.get("id"),
|
||||
user_id: r.get("user_id"),
|
||||
name: r.get("name"),
|
||||
version: r.get("version"),
|
||||
wit_version: r.get("wit_version"),
|
||||
description: r.get("description"),
|
||||
capabilities_json: r.get("capabilities_json"),
|
||||
status: r.get("status"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
};
|
||||
|
||||
Ok(StoredWasmChannelWithBinary {
|
||||
channel,
|
||||
wasm_binary,
|
||||
binary_hash,
|
||||
})
|
||||
}
|
||||
None => Err(WasmChannelStoreError::NotFound(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmChannel>, WasmChannelStoreError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = $1
|
||||
ORDER BY name
|
||||
"#,
|
||||
&[&user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|r| pg_row_to_channel(&r)).collect()
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, WasmChannelStoreError> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let result = client
|
||||
.execute(
|
||||
"DELETE FROM wasm_channels WHERE user_id = $1 AND name = $2",
|
||||
&[&user_id, &name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(result > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn pg_row_to_channel(
|
||||
row: &tokio_postgres::Row,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
Ok(StoredWasmChannel {
|
||||
id: row.get("id"),
|
||||
user_id: row.get("user_id"),
|
||||
name: row.get("name"),
|
||||
version: row.get("version"),
|
||||
wit_version: row.get("wit_version"),
|
||||
description: row.get("description"),
|
||||
capabilities_json: row.get("capabilities_json"),
|
||||
status: row.get("status"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== libSQL implementation ====================
|
||||
|
||||
/// libSQL/Turso implementation of WasmChannelStore.
|
||||
///
|
||||
/// Holds an `Arc<Database>` handle and creates a fresh connection per operation,
|
||||
/// matching the connection-per-request pattern used by the main `LibSqlBackend`.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub struct LibSqlWasmChannelStore {
|
||||
db: std::sync::Arc<libsql::Database>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
impl LibSqlWasmChannelStore {
|
||||
pub fn new(db: std::sync::Arc<libsql::Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<libsql::Connection, WasmChannelStoreError> {
|
||||
let conn = self
|
||||
.db
|
||||
.connect()
|
||||
.map_err(|e| WasmChannelStoreError::Database(format!("Connection failed: {}", e)))?;
|
||||
conn.query("PRAGMA busy_timeout = 5000", ())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WasmChannelStoreError::Database(format!("Failed to set busy_timeout: {}", e))
|
||||
})?;
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[async_trait]
|
||||
impl WasmChannelStore for LibSqlWasmChannelStore {
|
||||
async fn store(
|
||||
&self,
|
||||
params: StoreChannelParams,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let binary_hash = compute_binary_hash(¶ms.wasm_binary);
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let conn = self.connect().await?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
// Delete any existing version for this (user_id, name) — upgrade-in-place
|
||||
tx.execute(
|
||||
"DELETE FROM wasm_channels WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![params.user_id.as_str(), params.name.as_str()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO wasm_channels (
|
||||
id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'active', ?10, ?10)
|
||||
"#,
|
||||
libsql::params![
|
||||
id.to_string(),
|
||||
params.user_id.as_str(),
|
||||
params.name.as_str(),
|
||||
params.version.as_str(),
|
||||
params.wit_version.as_str(),
|
||||
params.description.as_str(),
|
||||
libsql::Value::Blob(params.wasm_binary),
|
||||
libsql::Value::Blob(binary_hash),
|
||||
params.capabilities_json.as_str(),
|
||||
now.as_str(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
// Read back the row within the same transaction
|
||||
let mut rows = tx
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = ?1 AND name = ?2
|
||||
"#,
|
||||
libsql::params![params.user_id.as_str(), params.name.as_str()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let row = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
WasmChannelStoreError::Database("Insert succeeded but row not found".into())
|
||||
})?;
|
||||
|
||||
let channel = libsql_row_to_channel(&row)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = ?1 AND name = ?2
|
||||
"#,
|
||||
libsql::params![user_id, name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?
|
||||
{
|
||||
Some(row) => libsql_row_to_channel(&row),
|
||||
None => Err(WasmChannelStoreError::NotFound(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_with_binary(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmChannelWithBinary, WasmChannelStoreError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
wasm_binary, binary_hash,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = ?1 AND name = ?2
|
||||
"#,
|
||||
libsql::params![user_id, name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?
|
||||
{
|
||||
Some(row) => {
|
||||
let wasm_binary: Vec<u8> = row
|
||||
.get(6)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
let binary_hash: Vec<u8> = row
|
||||
.get(7)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
if !verify_binary_integrity(&wasm_binary, &binary_hash) {
|
||||
tracing::error!(
|
||||
user_id = user_id,
|
||||
name = name,
|
||||
"WASM channel binary integrity check failed"
|
||||
);
|
||||
return Err(WasmChannelStoreError::IntegrityCheckFailed);
|
||||
}
|
||||
|
||||
let channel = libsql_row_to_channel_with_offset(&row)?;
|
||||
|
||||
Ok(StoredWasmChannelWithBinary {
|
||||
channel,
|
||||
wasm_binary,
|
||||
binary_hash,
|
||||
})
|
||||
}
|
||||
None => Err(WasmChannelStoreError::NotFound(name.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmChannel>, WasmChannelStoreError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
capabilities_json, status, created_at, updated_at
|
||||
FROM wasm_channels
|
||||
WHERE user_id = ?1
|
||||
ORDER BY name
|
||||
"#,
|
||||
libsql::params![user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
let mut channels = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?
|
||||
{
|
||||
channels.push(libsql_row_to_channel(&row)?);
|
||||
}
|
||||
Ok(channels)
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, WasmChannelStoreError> {
|
||||
let conn = self.connect().await?;
|
||||
let result = conn
|
||||
.execute(
|
||||
"DELETE FROM wasm_channels WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![user_id, name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(result > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[allow(dead_code)]
|
||||
fn libsql_channel_opt_text(s: Option<&str>) -> libsql::Value {
|
||||
match s {
|
||||
Some(s) => libsql::Value::Text(s.to_string()),
|
||||
None => libsql::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
fn libsql_channel_parse_ts(s: &str) -> Result<DateTime<Utc>, WasmChannelStoreError> {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return Ok(dt.with_timezone(&Utc));
|
||||
}
|
||||
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
|
||||
return Ok(ndt.and_utc());
|
||||
}
|
||||
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||
return Ok(ndt.and_utc());
|
||||
}
|
||||
Err(WasmChannelStoreError::InvalidData(format!(
|
||||
"unparseable timestamp: {:?}",
|
||||
s
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse a channel row with standard column order (no binary columns).
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5),
|
||||
/// capabilities_json(6), status(7), created_at(8), updated_at(9)
|
||||
#[cfg(feature = "libsql")]
|
||||
fn libsql_row_to_channel(row: &libsql::Row) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let id_str: String = row
|
||||
.get(0)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
let created_at_str: String = row
|
||||
.get(8)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
let updated_at_str: String = row
|
||||
.get(9)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(StoredWasmChannel {
|
||||
id: id_str
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| WasmChannelStoreError::InvalidData(e.to_string()))?,
|
||||
user_id: row
|
||||
.get(1)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
name: row
|
||||
.get(2)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
version: row
|
||||
.get(3)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
wit_version: row
|
||||
.get(4)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
description: row
|
||||
.get(5)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
capabilities_json: row
|
||||
.get(6)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
status: row
|
||||
.get(7)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
created_at: libsql_channel_parse_ts(&created_at_str)?,
|
||||
updated_at: libsql_channel_parse_ts(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a channel row when binary columns are present (get_with_binary query).
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5),
|
||||
/// wasm_binary(6), binary_hash(7),
|
||||
/// capabilities_json(8), status(9), created_at(10), updated_at(11)
|
||||
#[cfg(feature = "libsql")]
|
||||
fn libsql_row_to_channel_with_offset(
|
||||
row: &libsql::Row,
|
||||
) -> Result<StoredWasmChannel, WasmChannelStoreError> {
|
||||
let id_str: String = row
|
||||
.get(0)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
let created_at_str: String = row
|
||||
.get(10)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
let updated_at_str: String = row
|
||||
.get(11)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(StoredWasmChannel {
|
||||
id: id_str
|
||||
.parse()
|
||||
.map_err(|e: uuid::Error| WasmChannelStoreError::InvalidData(e.to_string()))?,
|
||||
user_id: row
|
||||
.get(1)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
name: row
|
||||
.get(2)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
version: row
|
||||
.get(3)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
wit_version: row
|
||||
.get(4)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
description: row
|
||||
.get(5)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
capabilities_json: row
|
||||
.get(8)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
status: row
|
||||
.get(9)
|
||||
.map_err(|e| WasmChannelStoreError::Database(e.to_string()))?,
|
||||
created_at: libsql_channel_parse_ts(&created_at_str)?,
|
||||
updated_at: libsql_channel_parse_ts(&updated_at_str)?,
|
||||
})
|
||||
}
|
||||
@@ -933,8 +933,19 @@ impl WasmChannel {
|
||||
Self::add_host_functions(&mut linker)?;
|
||||
|
||||
// Instantiate using the generated bindings
|
||||
let instance = SandboxedChannel::instantiate(store, &component, &linker)
|
||||
.map_err(|e| WasmChannelError::Instantiation(e.to_string()))?;
|
||||
let instance = SandboxedChannel::instantiate(store, &component, &linker).map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("near:agent") || msg.contains("import") {
|
||||
WasmChannelError::Instantiation(format!(
|
||||
"{msg}. This may indicate a WIT version mismatch — \
|
||||
the channel was compiled against a different WIT than the host supports \
|
||||
(host WIT: {}). Rebuild the channel against the current WIT.",
|
||||
crate::tools::wasm::WIT_CHANNEL_VERSION
|
||||
))
|
||||
} else {
|
||||
WasmChannelError::Instantiation(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
@@ -2580,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))
|
||||
});
|
||||
|
||||
+182
-14
@@ -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');
|
||||
@@ -1003,7 +1122,7 @@ function showAuthCard(data) {
|
||||
oauthBtn.className = 'auth-oauth';
|
||||
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
|
||||
oauthBtn.addEventListener('click', () => {
|
||||
window.open(data.auth_url, '_blank', 'width=600,height=700');
|
||||
openOAuthUrl(data.auth_url);
|
||||
});
|
||||
links.appendChild(oauthBtn);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -1921,7 +2063,7 @@ function renderAvailableExtensionCard(entry) {
|
||||
// OAuth popup if auth started during install (builtin creds)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + entry.display_name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
openOAuthUrl(res.auth_url);
|
||||
}
|
||||
loadExtensions();
|
||||
// Auto-open configure for WASM channels
|
||||
@@ -2079,7 +2221,7 @@ function renderExtensionCard(ext) {
|
||||
card.appendChild(url);
|
||||
}
|
||||
|
||||
if (ext.tools.length > 0) {
|
||||
if (ext.tools && ext.tools.length > 0) {
|
||||
const tools = document.createElement('div');
|
||||
tools.className = 'ext-tools';
|
||||
tools.textContent = 'Tools: ' + ext.tools.join(', ');
|
||||
@@ -2179,7 +2321,7 @@ function activateExtension(name) {
|
||||
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
openOAuthUrl(res.auth_url);
|
||||
}
|
||||
loadExtensions();
|
||||
return;
|
||||
@@ -2187,7 +2329,7 @@ function activateExtension(name) {
|
||||
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank');
|
||||
openOAuthUrl(res.auth_url);
|
||||
} else if (res.awaiting_token) {
|
||||
showConfigureModal(name);
|
||||
} else {
|
||||
@@ -2329,20 +2471,21 @@ function submitConfigureModal(name, fields) {
|
||||
body: { secrets },
|
||||
})
|
||||
.then((res) => {
|
||||
closeConfigureModal();
|
||||
if (res.success) {
|
||||
closeConfigureModal();
|
||||
if (res.auth_url) {
|
||||
// OAuth flow started — open consent popup. The auth_completed SSE will
|
||||
// not arrive immediately (it fires after OAuth callback), so show a toast now.
|
||||
showToast('Opening OAuth authorization for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
openOAuthUrl(res.auth_url);
|
||||
loadExtensions();
|
||||
}
|
||||
// For non-OAuth success: the server always broadcasts auth_completed SSE,
|
||||
// which will show the toast and refresh extensions — no need to do it here too.
|
||||
} else {
|
||||
// Keep modal open so the user can correct their input and retry.
|
||||
btns.forEach(function(b) { b.disabled = false; });
|
||||
showToast(res.message || 'Configuration failed', 'error');
|
||||
loadExtensions();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -2356,6 +2499,25 @@ function closeConfigureModal() {
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
// Validate that a server-supplied OAuth URL is HTTPS before opening a popup.
|
||||
// Rejects javascript:, data:, and other non-HTTPS schemes to prevent URL-injection.
|
||||
// Uses the URL constructor to safely parse and validate the scheme, which also
|
||||
// handles non-string values (objects, null, etc.) that would throw on .startsWith().
|
||||
function openOAuthUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error('non-HTTPS protocol: ' + parsed.protocol);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Blocked invalid/non-HTTPS OAuth URL:', url, e.message);
|
||||
showToast('Invalid OAuth URL returned by server', 'error');
|
||||
return;
|
||||
}
|
||||
window.open(parsed.href, '_blank', 'width=600,height=700');
|
||||
}
|
||||
|
||||
// --- Pairing ---
|
||||
|
||||
function loadPairingRequests(channel, container) {
|
||||
@@ -3274,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()
|
||||
};
|
||||
|
||||
+156
-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(
|
||||
@@ -298,6 +301,7 @@ CREATE TABLE IF NOT EXISTS wasm_tools (
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
wit_version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
description TEXT NOT NULL,
|
||||
wasm_binary BLOB NOT NULL,
|
||||
binary_hash BLOB NOT NULL,
|
||||
@@ -314,6 +318,24 @@ CREATE INDEX IF NOT EXISTS idx_wasm_tools_user ON wasm_tools(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_name ON wasm_tools(user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_status ON wasm_tools(status);
|
||||
|
||||
-- ==================== WASM Channel Extensions ====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wasm_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
wit_version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
wasm_binary BLOB NOT NULL,
|
||||
binary_hash BLOB NOT NULL,
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
-- ==================== Tool Capabilities ====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_capabilities (
|
||||
@@ -547,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(())
|
||||
}
|
||||
|
||||
+144
@@ -422,3 +422,147 @@ pub enum RoutineError {
|
||||
|
||||
/// Result type alias for the agent.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_error_display() {
|
||||
let err = ConfigError::MissingEnvVar("DATABASE_URL".to_string());
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("DATABASE_URL"),
|
||||
"Should mention the variable name: {msg}"
|
||||
);
|
||||
|
||||
let err = ConfigError::MissingRequired {
|
||||
key: "llm.model".to_string(),
|
||||
hint: "Set LLM_MODEL env var".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("llm.model"), "Should mention the key: {msg}");
|
||||
assert!(
|
||||
msg.contains("Set LLM_MODEL"),
|
||||
"Should include the hint: {msg}"
|
||||
);
|
||||
|
||||
let err = ConfigError::InvalidValue {
|
||||
key: "port".to_string(),
|
||||
message: "must be a number".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("port"), "Should mention the key: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_error_display() {
|
||||
let err = DatabaseError::NotFound {
|
||||
entity: "conversation".to_string(),
|
||||
id: "abc-123".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("conversation"), "Should mention entity: {msg}");
|
||||
assert!(msg.contains("abc-123"), "Should mention id: {msg}");
|
||||
|
||||
let err = DatabaseError::Query("syntax error near SELECT".to_string());
|
||||
assert!(err.to_string().contains("syntax error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_error_display() {
|
||||
let err = ChannelError::StartupFailed {
|
||||
name: "telegram".to_string(),
|
||||
reason: "invalid token".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("telegram"), "Should mention channel: {msg}");
|
||||
assert!(
|
||||
msg.contains("invalid token"),
|
||||
"Should mention reason: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_error_display() {
|
||||
let err = LlmError::ContextLengthExceeded {
|
||||
used: 100_000,
|
||||
limit: 50_000,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("100000"), "Should mention used tokens: {msg}");
|
||||
assert!(msg.contains("50000"), "Should mention limit: {msg}");
|
||||
|
||||
let err = LlmError::RateLimited {
|
||||
provider: "openai".to_string(),
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("openai"), "Should mention provider: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_error_display() {
|
||||
let err = JobError::MaxJobsExceeded { max: 5 };
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("5"), "Should mention max: {msg}");
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let err = JobError::NotFound { id };
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains(&id.to_string()),
|
||||
"Should mention job id: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_error_display() {
|
||||
let err = SafetyError::InjectionDetected {
|
||||
pattern: "SYSTEM:".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("SYSTEM:"), "Should mention pattern: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_error_display() {
|
||||
let err = WorkspaceError::DocumentNotFound {
|
||||
doc_type: "notes".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("notes"), "Should mention doc_type: {msg}");
|
||||
assert!(msg.contains("user1"), "Should mention user_id: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_error_display() {
|
||||
let err = RoutineError::InvalidCron {
|
||||
reason: "bad format".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("bad format"), "Should mention reason: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_error_from_conversions() {
|
||||
let config_err = ConfigError::MissingEnvVar("TEST".to_string());
|
||||
let err: Error = config_err.into();
|
||||
assert!(matches!(err, Error::Config(_)));
|
||||
|
||||
let db_err = DatabaseError::Query("test".to_string());
|
||||
let err: Error = db_err.into();
|
||||
assert!(matches!(err, Error::Database(_)));
|
||||
|
||||
let job_err = JobError::MaxJobsExceeded { max: 1 };
|
||||
let err: Error = job_err.into();
|
||||
assert!(matches!(err, Error::Job(_)));
|
||||
|
||||
let safety_err = SafetyError::ValidationFailed {
|
||||
reason: "test".to_string(),
|
||||
};
|
||||
let err: Error = safety_err.into();
|
||||
assert!(matches!(err, Error::Safety(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,78 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get detailed info about an installed extension (version, wit_version, host compatibility).
|
||||
pub async fn extension_info(&self, name: &str) -> Result<serde_json::Value, ExtensionError> {
|
||||
Self::validate_extension_name(name)?;
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
|
||||
match kind {
|
||||
ExtensionKind::WasmTool => {
|
||||
let cap_path = self
|
||||
.wasm_tools_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
||||
|
||||
let mut info = serde_json::json!({
|
||||
"name": name,
|
||||
"kind": "wasm_tool",
|
||||
"installed": wasm_path.exists(),
|
||||
});
|
||||
|
||||
if cap_path.exists()
|
||||
&& let Ok(bytes) = tokio::fs::read(&cap_path).await
|
||||
&& let Ok(cap) = crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes)
|
||||
{
|
||||
info["version"] =
|
||||
serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into()));
|
||||
info["wit_version"] =
|
||||
serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into()));
|
||||
}
|
||||
|
||||
info["host_wit_version"] = serde_json::json!(crate::tools::wasm::WIT_TOOL_VERSION);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
ExtensionKind::WasmChannel => {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
||||
|
||||
let mut info = serde_json::json!({
|
||||
"name": name,
|
||||
"kind": "wasm_channel",
|
||||
"installed": wasm_path.exists(),
|
||||
"active": self.active_channel_names.read().await.contains(name),
|
||||
});
|
||||
|
||||
if cap_path.exists()
|
||||
&& let Ok(bytes) = tokio::fs::read(&cap_path).await
|
||||
&& let Ok(cap) =
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes)
|
||||
{
|
||||
info["version"] =
|
||||
serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into()));
|
||||
info["wit_version"] =
|
||||
serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into()));
|
||||
}
|
||||
|
||||
info["host_wit_version"] =
|
||||
serde_json::json!(crate::tools::wasm::WIT_CHANNEL_VERSION);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
ExtensionKind::McpServer => {
|
||||
let info = serde_json::json!({
|
||||
"name": name,
|
||||
"kind": "mcp_server",
|
||||
"connected": self.mcp_clients.read().await.contains_key(name),
|
||||
});
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP config helpers (DB with disk fallback) ─────────────────────
|
||||
|
||||
async fn load_mcp_servers(
|
||||
|
||||
@@ -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()));
|
||||
|
||||
+315
-42
@@ -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.
|
||||
@@ -522,9 +554,6 @@ impl LlmProvider for NearAiChatProvider {
|
||||
reason: "No choices in response".to_string(),
|
||||
})?;
|
||||
|
||||
// Fall back to reasoning_content when content is null (e.g. GLM-5
|
||||
// returns its answer in reasoning_content instead of content).
|
||||
let content = choice.message.content.or(choice.message.reasoning_content);
|
||||
let tool_calls: Vec<ToolCall> = choice
|
||||
.message
|
||||
.tool_calls
|
||||
@@ -541,6 +570,18 @@ impl LlmProvider for NearAiChatProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fall back to reasoning_content when content is null (e.g. GLM-5
|
||||
// returns its answer in reasoning_content instead of content), but
|
||||
// only for final text responses. Tool-call responses often have
|
||||
// content: null + reasoning_content filled with chain-of-thought;
|
||||
// leaking that into conversation history inflates context and
|
||||
// confuses the model.
|
||||
let content = if tool_calls.is_empty() {
|
||||
choice.message.content.or(choice.message.reasoning_content)
|
||||
} else {
|
||||
choice.message.content
|
||||
};
|
||||
|
||||
let finish_reason = match choice.finish_reason.as_deref() {
|
||||
Some("stop") => FinishReason::Stop,
|
||||
Some("length") => FinishReason::Length,
|
||||
@@ -630,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")]
|
||||
@@ -798,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!(
|
||||
@@ -811,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,
|
||||
@@ -820,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,
|
||||
@@ -861,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 {
|
||||
@@ -974,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,
|
||||
@@ -1029,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]
|
||||
@@ -1103,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,
|
||||
@@ -1127,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,
|
||||
@@ -1148,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,
|
||||
@@ -1161,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]
|
||||
@@ -1186,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 {
|
||||
@@ -1200,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,
|
||||
@@ -1208,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]
|
||||
@@ -1285,4 +1361,201 @@ mod tests {
|
||||
assert_eq!(input, default_in);
|
||||
assert_eq!(output, default_out);
|
||||
}
|
||||
|
||||
/// Regression: reasoning_content must NOT leak into tool-call responses.
|
||||
#[test]
|
||||
fn test_reasoning_content_not_leaked_into_tool_call_response() {
|
||||
let response: ChatCompletionResponse = serde_json::from_value(serde_json::json!({
|
||||
"id": "chatcmpl-test",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"reasoning_content": "Let me think about which tool to call...",
|
||||
"tool_calls": [{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": "{\"query\":\"test\"}"
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": { "prompt_tokens": 100, "completion_tokens": 50 }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let choice = response.choices.into_iter().next().unwrap();
|
||||
let tool_calls: Vec<ToolCall> = choice
|
||||
.message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| {
|
||||
let arguments = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
ToolCall {
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = if tool_calls.is_empty() {
|
||||
choice.message.content.or(choice.message.reasoning_content)
|
||||
} else {
|
||||
choice.message.content
|
||||
};
|
||||
|
||||
assert!(
|
||||
content.is_none(),
|
||||
"reasoning_content should NOT leak into tool-call responses, got: {:?}",
|
||||
content
|
||||
);
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "search");
|
||||
}
|
||||
|
||||
/// Regression: reasoning_content SHOULD be used as fallback for text responses.
|
||||
#[test]
|
||||
fn test_reasoning_content_used_for_text_response() {
|
||||
let response: ChatCompletionResponse = serde_json::from_value(serde_json::json!({
|
||||
"id": "chatcmpl-test",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"reasoning_content": "The answer is 42."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": { "prompt_tokens": 50, "completion_tokens": 20 }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let choice = response.choices.into_iter().next().unwrap();
|
||||
let tool_calls: Vec<ToolCall> = choice
|
||||
.message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| {
|
||||
let arguments = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
ToolCall {
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = if tool_calls.is_empty() {
|
||||
choice.message.content.or(choice.message.reasoning_content)
|
||||
} else {
|
||||
choice.message.content
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
content,
|
||||
Some("The answer is 42.".to_string()),
|
||||
"reasoning_content should be used as fallback for text responses"
|
||||
);
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-3
@@ -335,8 +335,9 @@ impl Reasoning {
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
|
||||
// Parse the plan from the response
|
||||
self.parse_plan(&response.content)
|
||||
// Clean reasoning model artifacts before parsing JSON
|
||||
let cleaned = clean_response(&response.content);
|
||||
self.parse_plan(&cleaned)
|
||||
}
|
||||
|
||||
/// Select the best tool for the current situation.
|
||||
@@ -429,7 +430,9 @@ Respond in JSON format:
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
|
||||
self.parse_evaluation(&response.content)
|
||||
// Clean reasoning model artifacts before parsing JSON
|
||||
let cleaned = clean_response(&response.content);
|
||||
self.parse_evaluation(&cleaned)
|
||||
}
|
||||
|
||||
/// Generate a response to a user message.
|
||||
@@ -1292,8 +1295,15 @@ fn strip_thinking_tags_regex(text: &str, code_regions: &[CodeRegion]) -> String
|
||||
}
|
||||
|
||||
// Strict mode: if still inside an unclosed thinking tag, discard trailing text
|
||||
// BUT preserve any <final> block embedded in the discarded region
|
||||
if !in_thinking {
|
||||
result.push_str(&text[last_index..]);
|
||||
} else {
|
||||
let trailing = &text[last_index..];
|
||||
let trailing_regions = find_code_regions(trailing);
|
||||
if let Some(final_content) = extract_final_content(trailing, &trailing_regions) {
|
||||
result.push_str(&final_content);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
@@ -1918,6 +1928,59 @@ That's my plan."#;
|
||||
assert_eq!(calls[0].name, "tool_list");
|
||||
}
|
||||
|
||||
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
||||
|
||||
#[test]
|
||||
fn test_clean_response_strips_think_before_json_plan() {
|
||||
let raw = r#"<think>I need to plan the steps carefully...</think>{"steps": [{"description": "Step 1", "tool": "search", "expected_outcome": "results"}], "reasoning": "Simple plan"}"#;
|
||||
let cleaned = clean_response(raw);
|
||||
// After cleaning, the JSON should be parseable
|
||||
let json_str = extract_json(&cleaned).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap();
|
||||
assert!(parsed.get("steps").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_response_strips_think_before_json_evaluation() {
|
||||
let raw = r#"<think>Let me evaluate whether this was successful...</think>{"success": true, "confidence": 0.95, "reasoning": "Task completed", "issues": [], "suggestions": []}"#;
|
||||
let cleaned = clean_response(raw);
|
||||
let json_str = extract_json(&cleaned).unwrap();
|
||||
let eval: SuccessEvaluation = serde_json::from_str(json_str).unwrap();
|
||||
assert!(eval.success);
|
||||
assert_eq!(eval.confidence, 0.95);
|
||||
}
|
||||
|
||||
// ---- Unclosed think before final (Bug #564-3) ----
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_think_before_final() {
|
||||
assert_eq!(
|
||||
clean_response("<think>reasoning no close tag <final>actual answer</final>"),
|
||||
"actual answer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_thinking_before_final() {
|
||||
assert_eq!(
|
||||
clean_response("<thinking>long reasoning... <final>the real answer</final>"),
|
||||
"the real answer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_think_before_final_with_prefix() {
|
||||
assert_eq!(
|
||||
clean_response("Hello <think>reasoning <final>world</final>"),
|
||||
"Hello world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_think_no_final_still_discards() {
|
||||
assert_eq!(clean_response("Hello <thinking>this never closes"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_bracket_format_tool_call() {
|
||||
let tools = make_tools(&["http"]);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+333
-33
@@ -16,13 +16,14 @@
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
@@ -30,6 +31,9 @@ use crate::llm::provider::{
|
||||
ToolCompletionResponse,
|
||||
};
|
||||
|
||||
/// How often (in requests) to emit a cache statistics log line.
|
||||
const STATS_LOG_EVERY_N: u64 = 100;
|
||||
|
||||
/// Configuration for the response cache.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponseCacheConfig {
|
||||
@@ -61,8 +65,16 @@ struct CacheEntry {
|
||||
/// tool calls can have side effects that should not be replayed.
|
||||
pub struct CachedProvider {
|
||||
inner: Arc<dyn LlmProvider>,
|
||||
/// `std::sync::Mutex` (not tokio) — never held across an `.await` point,
|
||||
/// so blocking acquisition is safe and keeps `set_model()` synchronous.
|
||||
cache: Mutex<HashMap<String, CacheEntry>>,
|
||||
config: ResponseCacheConfig,
|
||||
/// Total `complete()` calls (hits + misses) for periodic stats logging.
|
||||
request_count: AtomicU64,
|
||||
/// Running total of cache hits, independent of entry lifecycle.
|
||||
/// Never decremented on eviction, so `hit_rate_pct` in stats doesn't
|
||||
/// drift down as entries expire or are LRU-evicted.
|
||||
total_hit_count: AtomicU64,
|
||||
}
|
||||
|
||||
impl CachedProvider {
|
||||
@@ -72,27 +84,53 @@ impl CachedProvider {
|
||||
inner,
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
config,
|
||||
request_count: AtomicU64::new(0),
|
||||
total_hit_count: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of entries currently in the cache.
|
||||
pub async fn len(&self) -> usize {
|
||||
self.cache.lock().await.len()
|
||||
pub fn len(&self) -> usize {
|
||||
self.cache.lock().unwrap_or_else(|e| e.into_inner()).len()
|
||||
}
|
||||
|
||||
/// Whether the cache is empty.
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.cache.lock().await.is_empty()
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
/// Total cache hits across all entries.
|
||||
pub async fn total_hits(&self) -> u64 {
|
||||
self.cache.lock().await.values().map(|e| e.hit_count).sum()
|
||||
/// Total cache hits since this provider was created.
|
||||
///
|
||||
/// Backed by an atomic counter that is never decremented on eviction,
|
||||
/// so the value is accurate even under high eviction pressure.
|
||||
pub fn total_hits(&self) -> u64 {
|
||||
self.total_hit_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Clear all cached entries.
|
||||
pub async fn clear(&self) {
|
||||
self.cache.lock().await.clear();
|
||||
pub fn clear(&self) {
|
||||
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
|
||||
/// Emit a cache statistics log line if `req_no` is a multiple of
|
||||
/// [`STATS_LOG_EVERY_N`]. `total_hits` must come from the `total_hit_count`
|
||||
/// atomic so it accurately reflects hits that occurred on since-evicted
|
||||
/// entries. Must be called while holding the cache lock so that
|
||||
/// `entry_count` is consistent with the snapshot.
|
||||
fn maybe_log_stats(guard: &HashMap<String, CacheEntry>, req_no: u64, total_hits: u64) {
|
||||
if req_no.is_multiple_of(STATS_LOG_EVERY_N) {
|
||||
let hit_rate = total_hits as f64 / req_no as f64 * 100.0;
|
||||
tracing::info!(
|
||||
total_requests = req_no,
|
||||
total_hits,
|
||||
hit_rate_pct = format!("{hit_rate:.1}"),
|
||||
entry_count = guard.len(),
|
||||
"LLM response cache statistics"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,28 +185,47 @@ impl LlmProvider for CachedProvider {
|
||||
let effective_model = self.inner.effective_model_name(request.model.as_deref());
|
||||
let key = cache_key(&effective_model, &request);
|
||||
let now = Instant::now();
|
||||
let req_no = self.request_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
// Check cache
|
||||
// Check cache — lock not held across the .await below.
|
||||
{
|
||||
let mut guard = self.cache.lock().await;
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.get_mut(&key) {
|
||||
if now.duration_since(entry.created_at) < self.config.ttl {
|
||||
entry.last_accessed = now;
|
||||
entry.hit_count += 1;
|
||||
tracing::debug!(hits = entry.hit_count, "response cache hit");
|
||||
return Ok(entry.response.clone());
|
||||
let hit_count = entry.hit_count;
|
||||
// Clone now so we can release the mutable borrow before stats.
|
||||
let cached_response = entry.response.clone();
|
||||
tracing::debug!(hits = hit_count, "response cache hit");
|
||||
// Drop the mutable borrow of `entry` before reading `guard` immutably.
|
||||
let _ = entry;
|
||||
let total_hits = self.total_hit_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
Self::maybe_log_stats(&guard, req_no, total_hits);
|
||||
return Ok(cached_response);
|
||||
}
|
||||
// Expired, remove it
|
||||
guard.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss, call the real provider
|
||||
let response = self.inner.complete(request).await?;
|
||||
// Cache miss — call the real provider.
|
||||
let result = self.inner.complete(request).await;
|
||||
|
||||
// Store in cache
|
||||
// Store result and maybe log stats, all within one lock acquisition.
|
||||
// Stats are logged even on provider error so milestone intervals are
|
||||
// not silently skipped.
|
||||
{
|
||||
let mut guard = self.cache.lock().await;
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let total_hits = self.total_hit_count.load(Ordering::Relaxed);
|
||||
|
||||
let response = match result {
|
||||
Err(e) => {
|
||||
Self::maybe_log_stats(&guard, req_no, total_hits);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(r) => r,
|
||||
};
|
||||
|
||||
// Evict expired entries
|
||||
guard.retain(|_, entry| now.duration_since(entry.created_at) < self.config.ttl);
|
||||
@@ -196,9 +253,10 @@ impl LlmProvider for CachedProvider {
|
||||
hit_count: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
Self::maybe_log_stats(&guard, req_no, total_hits);
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
@@ -226,16 +284,91 @@ impl LlmProvider for CachedProvider {
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||
// Cache keys embed the active model name via `effective_model_name()`, so
|
||||
// requests to the new model automatically land in a separate cache slot.
|
||||
// Entries for the old model remain valid: if we switch back, they will be
|
||||
// hit again rather than wasted. Natural TTL / LRU eviction cleans them up.
|
||||
self.inner.set_model(model)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::llm::provider::ChatMessage;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionResponse, FinishReason, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
};
|
||||
use crate::llm::response_cache::*;
|
||||
use crate::testing::StubLlm;
|
||||
|
||||
/// Minimal provider stub that supports `set_model()` — used to test
|
||||
/// per-model cache key isolation.
|
||||
struct SwitchableStub {
|
||||
call_count: AtomicU32,
|
||||
active_model: std::sync::RwLock<String>,
|
||||
}
|
||||
|
||||
impl SwitchableStub {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
call_count: AtomicU32::new(0),
|
||||
active_model: std::sync::RwLock::new("stub-model".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for SwitchableStub {
|
||||
fn model_name(&self) -> &str {
|
||||
"stub-model"
|
||||
}
|
||||
|
||||
fn active_model_name(&self) -> String {
|
||||
self.active_model.read().unwrap().clone()
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||
*self.active_model.write().unwrap() = model.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(CompletionResponse {
|
||||
content: "ok".into(),
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
finish_reason: FinishReason::Stop,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
_request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
Ok(ToolCompletionResponse {
|
||||
content: Some("ok".into()),
|
||||
tool_calls: vec![],
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
finish_reason: FinishReason::Stop,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_request() -> CompletionRequest {
|
||||
CompletionRequest {
|
||||
messages: vec![ChatMessage::user("hello")],
|
||||
@@ -321,7 +454,7 @@ mod tests {
|
||||
assert_eq!(stub.calls(), 1); // still 1
|
||||
assert_eq!(r2.content, "cached response");
|
||||
|
||||
assert_eq!(cached.total_hits().await, 1);
|
||||
assert_eq!(cached.total_hits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -333,7 +466,7 @@ mod tests {
|
||||
cached.complete(different_request()).await.unwrap();
|
||||
|
||||
assert_eq!(stub.calls(), 2);
|
||||
assert_eq!(cached.len().await, 2);
|
||||
assert_eq!(cached.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -372,7 +505,7 @@ mod tests {
|
||||
// Fill cache with 2 entries
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
cached.complete(different_request()).await.unwrap();
|
||||
assert_eq!(cached.len().await, 2);
|
||||
assert_eq!(cached.len(), 2);
|
||||
|
||||
// Add a third: should evict the oldest
|
||||
let third = CompletionRequest {
|
||||
@@ -384,7 +517,7 @@ mod tests {
|
||||
metadata: Default::default(),
|
||||
};
|
||||
cached.complete(third).await.unwrap();
|
||||
assert_eq!(cached.len().await, 2);
|
||||
assert_eq!(cached.len(), 2);
|
||||
assert_eq!(stub.calls(), 3);
|
||||
}
|
||||
|
||||
@@ -408,7 +541,7 @@ mod tests {
|
||||
|
||||
// Both should have called through
|
||||
assert_eq!(stub.calls(), 2);
|
||||
assert!(cached.is_empty().await);
|
||||
assert!(cached.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -425,12 +558,12 @@ mod tests {
|
||||
stub.set_failing(true);
|
||||
let result = cached.complete(simple_request()).await;
|
||||
assert!(result.is_err());
|
||||
assert!(cached.is_empty().await);
|
||||
assert!(cached.is_empty());
|
||||
|
||||
// After fixing the provider, should succeed and cache
|
||||
stub.set_failing(false);
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(cached.len().await, 1);
|
||||
assert_eq!(cached.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -439,10 +572,10 @@ mod tests {
|
||||
let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default());
|
||||
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(cached.len().await, 1);
|
||||
assert_eq!(cached.len(), 1);
|
||||
|
||||
cached.clear().await;
|
||||
assert!(cached.is_empty().await);
|
||||
cached.clear();
|
||||
assert!(cached.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -459,7 +592,7 @@ mod tests {
|
||||
cached.complete(req_b).await.unwrap();
|
||||
|
||||
assert_eq!(stub.calls(), 2);
|
||||
assert_eq!(cached.len().await, 2);
|
||||
assert_eq!(cached.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -475,4 +608,171 @@ mod tests {
|
||||
let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default());
|
||||
assert_eq!(cached.model_name(), "stub-model");
|
||||
}
|
||||
|
||||
/// Switching models preserves existing cached entries and routes subsequent
|
||||
/// requests to a separate cache slot. Switching back replays the old slot.
|
||||
#[tokio::test]
|
||||
async fn set_model_isolates_per_model_via_key() {
|
||||
let stub = Arc::new(SwitchableStub::new());
|
||||
let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default());
|
||||
|
||||
// Populate cache under the initial model ("stub-model").
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(stub.call_count.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(cached.len(), 1, "one entry cached for stub-model");
|
||||
|
||||
// Switch to a different model — old entries must survive.
|
||||
cached.set_model("model-b").unwrap();
|
||||
assert_eq!(cached.len(), 1, "old entries preserved after model switch");
|
||||
|
||||
// Same request under model-b is a cache miss (different key).
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(
|
||||
stub.call_count.load(Ordering::Relaxed),
|
||||
2,
|
||||
"cache miss for model-b"
|
||||
);
|
||||
assert_eq!(cached.len(), 2, "separate slots for stub-model and model-b");
|
||||
|
||||
// Switch back — original slot is still valid (cache hit, no extra call).
|
||||
cached.set_model("stub-model").unwrap();
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(
|
||||
stub.call_count.load(Ordering::Relaxed),
|
||||
2,
|
||||
"cache hit when switching back to stub-model"
|
||||
);
|
||||
}
|
||||
|
||||
/// When `set_model()` fails the error is propagated and the cache is unaffected.
|
||||
#[tokio::test]
|
||||
async fn set_model_error_leaves_cache_intact() {
|
||||
// StubLlm does not override set_model() — returns an error by default.
|
||||
let stub = Arc::new(StubLlm::default());
|
||||
let cached = CachedProvider::new(stub, ResponseCacheConfig::default());
|
||||
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(cached.len(), 1);
|
||||
|
||||
let result = cached.set_model("new-model");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(cached.len(), 1, "cache unaffected by failed set_model");
|
||||
}
|
||||
|
||||
/// `hit_rate_pct` stays accurate even after entries are evicted.
|
||||
/// The `total_hit_count` atomic is never decremented on eviction.
|
||||
#[tokio::test]
|
||||
async fn total_hits_survives_eviction() {
|
||||
let stub = Arc::new(StubLlm::new("response"));
|
||||
// max_entries = 1 so the first entry is LRU-evicted when a second arrives.
|
||||
let cached = CachedProvider::new(
|
||||
stub.clone(),
|
||||
ResponseCacheConfig {
|
||||
ttl: Duration::from_secs(60),
|
||||
max_entries: 1,
|
||||
},
|
||||
);
|
||||
|
||||
// Populate the cache and score a hit.
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
cached.complete(simple_request()).await.unwrap();
|
||||
assert_eq!(cached.total_hits(), 1);
|
||||
|
||||
// Add a different request — LRU evicts the first entry.
|
||||
cached.complete(different_request()).await.unwrap();
|
||||
assert_eq!(cached.len(), 1, "first entry was evicted");
|
||||
|
||||
// The hit from the evicted entry must still be counted.
|
||||
assert_eq!(cached.total_hits(), 1, "hit count survives eviction");
|
||||
}
|
||||
|
||||
/// A stats line is emitted exactly at the 100th request.
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn stats_logged_at_request_100() {
|
||||
let stub = Arc::new(StubLlm::new("response"));
|
||||
let cached = CachedProvider::new(
|
||||
stub.clone(),
|
||||
ResponseCacheConfig {
|
||||
ttl: Duration::from_secs(60),
|
||||
max_entries: 2000,
|
||||
},
|
||||
);
|
||||
|
||||
// 99 distinct requests — no stats line yet.
|
||||
for i in 0..99u32 {
|
||||
let req = CompletionRequest {
|
||||
messages: vec![ChatMessage::user(format!("request {i}"))],
|
||||
model: None,
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
stop_sequences: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
cached.complete(req).await.unwrap();
|
||||
}
|
||||
assert!(
|
||||
!logs_contain("LLM response cache statistics"),
|
||||
"no stats before request 100"
|
||||
);
|
||||
|
||||
// 100th request triggers the first stats line.
|
||||
let req = CompletionRequest {
|
||||
messages: vec![ChatMessage::user("request 99")],
|
||||
model: None,
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
stop_sequences: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
cached.complete(req).await.unwrap();
|
||||
assert!(
|
||||
logs_contain("LLM response cache statistics"),
|
||||
"stats emitted at request 100"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stats are emitted even when the inner provider returns an error.
|
||||
#[tokio::test]
|
||||
#[traced_test]
|
||||
async fn stats_logged_on_provider_error_at_interval() {
|
||||
let stub = Arc::new(StubLlm::new("response"));
|
||||
let cached = CachedProvider::new(
|
||||
stub.clone(),
|
||||
ResponseCacheConfig {
|
||||
ttl: Duration::from_secs(60),
|
||||
max_entries: 2000,
|
||||
},
|
||||
);
|
||||
|
||||
// 99 successful requests.
|
||||
for i in 0..99u32 {
|
||||
let req = CompletionRequest {
|
||||
messages: vec![ChatMessage::user(format!("req {i}"))],
|
||||
model: None,
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
stop_sequences: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
cached.complete(req).await.unwrap();
|
||||
}
|
||||
|
||||
// 100th request fails — stats must still be logged.
|
||||
stub.set_failing(true);
|
||||
let req = CompletionRequest {
|
||||
messages: vec![ChatMessage::user("req 99")],
|
||||
model: None,
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
stop_sequences: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
let result = cached.complete(req).await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
logs_contain("LLM response cache statistics"),
|
||||
"stats emitted even when provider errors on request 100"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+579
@@ -652,4 +652,583 @@ mod tests {
|
||||
assert_eq!(response.content, "hello world");
|
||||
assert_eq!(response.finish_reason, FinishReason::Stop);
|
||||
}
|
||||
|
||||
// === Database CRUD coverage for untested trait methods ===
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_settings_crud() {
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
// Initially no setting
|
||||
let val = db.get_setting("user1", "theme").await.expect("get");
|
||||
assert!(val.is_none());
|
||||
|
||||
// Set a value
|
||||
db.set_setting("user1", "theme", &serde_json::json!("dark"))
|
||||
.await
|
||||
.expect("set");
|
||||
|
||||
// Read it back
|
||||
let val = db
|
||||
.get_setting("user1", "theme")
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("should exist");
|
||||
assert_eq!(val, serde_json::json!("dark"));
|
||||
|
||||
// Update it
|
||||
db.set_setting("user1", "theme", &serde_json::json!("light"))
|
||||
.await
|
||||
.expect("set update");
|
||||
let val = db
|
||||
.get_setting("user1", "theme")
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("should exist");
|
||||
assert_eq!(val, serde_json::json!("light"));
|
||||
|
||||
// List settings
|
||||
let all = db.list_settings("user1").await.expect("list");
|
||||
assert_eq!(all.len(), 1);
|
||||
|
||||
// Delete
|
||||
let deleted = db.delete_setting("user1", "theme").await.expect("delete");
|
||||
assert!(deleted);
|
||||
|
||||
let val = db.get_setting("user1", "theme").await.expect("get");
|
||||
assert!(val.is_none());
|
||||
|
||||
// Delete non-existent
|
||||
let deleted = db.delete_setting("user1", "theme").await.expect("delete");
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_settings_bulk_operations() {
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
// Initially no settings
|
||||
let has = db.has_settings("bulk_user").await.expect("has_settings");
|
||||
assert!(!has);
|
||||
|
||||
// Set all settings at once
|
||||
let mut settings = std::collections::HashMap::new();
|
||||
settings.insert("key1".to_string(), serde_json::json!("value1"));
|
||||
settings.insert("key2".to_string(), serde_json::json!(42));
|
||||
db.set_all_settings("bulk_user", &settings)
|
||||
.await
|
||||
.expect("set_all");
|
||||
|
||||
// Has settings should now be true
|
||||
let has = db.has_settings("bulk_user").await.expect("has_settings");
|
||||
assert!(has);
|
||||
|
||||
// Get all settings
|
||||
let all = db.get_all_settings("bulk_user").await.expect("get_all");
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all["key1"], serde_json::json!("value1"));
|
||||
assert_eq!(all["key2"], serde_json::json!(42));
|
||||
|
||||
// Get full setting row
|
||||
let full = db
|
||||
.get_setting_full("bulk_user", "key1")
|
||||
.await
|
||||
.expect("get_full")
|
||||
.expect("should exist");
|
||||
assert_eq!(full.key, "key1");
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_tool_failure_tracking() {
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
// Record some failures
|
||||
db.record_tool_failure("bad_tool", "connection refused")
|
||||
.await
|
||||
.expect("record 1");
|
||||
db.record_tool_failure("bad_tool", "timeout")
|
||||
.await
|
||||
.expect("record 2");
|
||||
db.record_tool_failure("bad_tool", "parse error")
|
||||
.await
|
||||
.expect("record 3");
|
||||
|
||||
// Get broken tools (threshold = 2, should include bad_tool with 3 failures)
|
||||
let broken = db.get_broken_tools(2).await.expect("get broken");
|
||||
assert!(!broken.is_empty());
|
||||
let found = broken.iter().find(|b| b.name == "bad_tool");
|
||||
assert!(found.is_some(), "bad_tool should be in broken tools list");
|
||||
|
||||
// Mark as repaired
|
||||
db.mark_tool_repaired("bad_tool")
|
||||
.await
|
||||
.expect("mark repaired");
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_routine_crud() {
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
|
||||
};
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let routine_id = uuid::Uuid::new_v4();
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: "test-routine".to_string(),
|
||||
description: "A test routine".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Cron {
|
||||
schedule: "0 * * * *".to_string(),
|
||||
},
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "Check status".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 500,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(60),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: NotifyConfig {
|
||||
channel: None,
|
||||
user: "user1".to_string(),
|
||||
on_attention: true,
|
||||
on_failure: true,
|
||||
on_success: false,
|
||||
},
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// Create
|
||||
db.create_routine(&routine).await.expect("create routine");
|
||||
|
||||
// Get by ID
|
||||
let fetched = db
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.expect("get routine")
|
||||
.expect("should exist");
|
||||
assert_eq!(fetched.name, "test-routine");
|
||||
assert!(fetched.enabled);
|
||||
|
||||
// Get by name
|
||||
let by_name = db
|
||||
.get_routine_by_name("user1", "test-routine")
|
||||
.await
|
||||
.expect("get by name")
|
||||
.expect("should exist");
|
||||
assert_eq!(by_name.id, routine_id);
|
||||
|
||||
// List routines for user
|
||||
let list = db.list_routines("user1").await.expect("list routines");
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
// List all routines
|
||||
let all = db.list_all_routines().await.expect("list all");
|
||||
assert!(!all.is_empty());
|
||||
|
||||
// Update routine (disable + change description)
|
||||
let mut updated = fetched;
|
||||
updated.enabled = false;
|
||||
updated.description = "Updated description".to_string();
|
||||
db.update_routine(&updated).await.expect("update routine");
|
||||
|
||||
let re_fetched = db
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("exists");
|
||||
assert!(!re_fetched.enabled);
|
||||
assert_eq!(re_fetched.description, "Updated description");
|
||||
|
||||
// Create a routine run
|
||||
let run_id = uuid::Uuid::new_v4();
|
||||
let run = RoutineRun {
|
||||
id: run_id,
|
||||
routine_id,
|
||||
trigger_type: "cron".to_string(),
|
||||
trigger_detail: Some("0 * * * *".to_string()),
|
||||
started_at: chrono::Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
db.create_routine_run(&run).await.expect("create run");
|
||||
|
||||
// List runs
|
||||
let runs = db
|
||||
.list_routine_runs(routine_id, 10)
|
||||
.await
|
||||
.expect("list runs");
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert!(matches!(runs[0].status, RunStatus::Running));
|
||||
|
||||
// Complete the run
|
||||
db.complete_routine_run(run_id, RunStatus::Ok, Some("All good"), Some(150))
|
||||
.await
|
||||
.expect("complete run");
|
||||
|
||||
let runs = db
|
||||
.list_routine_runs(routine_id, 10)
|
||||
.await
|
||||
.expect("list runs after complete");
|
||||
assert!(matches!(runs[0].status, RunStatus::Ok));
|
||||
|
||||
// Delete
|
||||
let deleted = db.delete_routine(routine_id).await.expect("delete");
|
||||
assert!(deleted);
|
||||
|
||||
// Delete non-existent
|
||||
let deleted = db.delete_routine(routine_id).await.expect("delete again");
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_routine_runtime_update() {
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||
};
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let routine_id = uuid::Uuid::new_v4();
|
||||
let routine = Routine {
|
||||
id: routine_id,
|
||||
name: "runtime-test".to_string(),
|
||||
description: "Test runtime update".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
enabled: true,
|
||||
trigger: Trigger::Manual,
|
||||
action: RoutineAction::Lightweight {
|
||||
prompt: "test".to_string(),
|
||||
context_paths: vec![],
|
||||
max_tokens: 100,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(0),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
},
|
||||
notify: NotifyConfig {
|
||||
channel: None,
|
||||
user: "user1".to_string(),
|
||||
on_attention: false,
|
||||
on_failure: false,
|
||||
on_success: false,
|
||||
},
|
||||
last_run_at: None,
|
||||
next_fire_at: None,
|
||||
run_count: 0,
|
||||
consecutive_failures: 0,
|
||||
state: serde_json::json!({}),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
db.create_routine(&routine).await.expect("create");
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
db.update_routine_runtime(
|
||||
routine_id,
|
||||
now,
|
||||
Some(now + chrono::TimeDelta::seconds(3600)),
|
||||
5,
|
||||
2,
|
||||
&serde_json::json!({"last_result": "ok"}),
|
||||
)
|
||||
.await
|
||||
.expect("update runtime");
|
||||
|
||||
let fetched = db
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("exists");
|
||||
assert_eq!(fetched.run_count, 5);
|
||||
assert_eq!(fetched.consecutive_failures, 2);
|
||||
assert!(fetched.last_run_at.is_some());
|
||||
assert!(fetched.next_fire_at.is_some());
|
||||
|
||||
// Cleanup
|
||||
db.delete_routine(routine_id).await.expect("delete");
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_llm_call_recording() {
|
||||
use crate::history::LlmCallRecord;
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let record = LlmCallRecord {
|
||||
job_id: None,
|
||||
conversation_id: None,
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cost: Decimal::new(5, 3), // 0.005
|
||||
purpose: Some("test"),
|
||||
};
|
||||
|
||||
let call_id = db.record_llm_call(&record).await.expect("record llm call");
|
||||
assert!(!call_id.is_nil());
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_sandbox_job_lifecycle() {
|
||||
use crate::history::SandboxJobRecord;
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let job_id = uuid::Uuid::new_v4();
|
||||
let job = SandboxJobRecord {
|
||||
id: job_id,
|
||||
task: "Build a test tool".to_string(),
|
||||
status: "creating".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
project_dir: "/workspace/test".to_string(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: "[]".to_string(),
|
||||
};
|
||||
|
||||
// Create
|
||||
db.save_sandbox_job(&job).await.expect("save sandbox job");
|
||||
|
||||
// Get
|
||||
let fetched = db
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("should exist");
|
||||
assert_eq!(fetched.task, "Build a test tool");
|
||||
assert_eq!(fetched.status, "creating");
|
||||
|
||||
// Update status to running
|
||||
db.update_sandbox_job_status(
|
||||
job_id,
|
||||
"running",
|
||||
None,
|
||||
None,
|
||||
Some(chrono::Utc::now()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("update to running");
|
||||
|
||||
// Update to completed
|
||||
db.update_sandbox_job_status(
|
||||
job_id,
|
||||
"completed",
|
||||
Some(true),
|
||||
Some("Done"),
|
||||
None,
|
||||
Some(chrono::Utc::now()),
|
||||
)
|
||||
.await
|
||||
.expect("update to completed");
|
||||
|
||||
let fetched = db
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.expect("get")
|
||||
.expect("should exist");
|
||||
assert_eq!(fetched.status, "completed");
|
||||
assert_eq!(fetched.success, Some(true));
|
||||
|
||||
// List
|
||||
let all = db.list_sandbox_jobs().await.expect("list");
|
||||
assert!(!all.is_empty());
|
||||
|
||||
// Summary
|
||||
let summary = db.sandbox_job_summary().await.expect("summary");
|
||||
assert!(summary.total >= 1);
|
||||
|
||||
// Per-user list
|
||||
let user_jobs = db
|
||||
.list_sandbox_jobs_for_user("user1")
|
||||
.await
|
||||
.expect("user list");
|
||||
assert!(!user_jobs.is_empty());
|
||||
|
||||
// Ownership check
|
||||
let belongs = db
|
||||
.sandbox_job_belongs_to_user(job_id, "user1")
|
||||
.await
|
||||
.expect("belongs check");
|
||||
assert!(belongs);
|
||||
let not_belongs = db
|
||||
.sandbox_job_belongs_to_user(job_id, "other_user")
|
||||
.await
|
||||
.expect("belongs check");
|
||||
assert!(!not_belongs);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_sandbox_job_mode() {
|
||||
use crate::history::SandboxJobRecord;
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let job_id = uuid::Uuid::new_v4();
|
||||
let job = SandboxJobRecord {
|
||||
id: job_id,
|
||||
task: "Mode test".to_string(),
|
||||
status: "creating".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
project_dir: "/workspace".to_string(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: "[]".to_string(),
|
||||
};
|
||||
db.save_sandbox_job(&job).await.expect("save");
|
||||
|
||||
// Default mode
|
||||
let mode = db.get_sandbox_job_mode(job_id).await.expect("get mode");
|
||||
// Default is "worker" per schema or NULL
|
||||
assert!(mode.is_none() || mode.as_deref() == Some("worker"));
|
||||
|
||||
// Update mode
|
||||
db.update_sandbox_job_mode(job_id, "claude_code")
|
||||
.await
|
||||
.expect("update mode");
|
||||
let mode = db
|
||||
.get_sandbox_job_mode(job_id)
|
||||
.await
|
||||
.expect("get mode")
|
||||
.expect("should have mode");
|
||||
assert_eq!(mode, "claude_code");
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_job_events() {
|
||||
use crate::history::SandboxJobRecord;
|
||||
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
// Create a sandbox job first (foreign key)
|
||||
let job_id = uuid::Uuid::new_v4();
|
||||
let job = SandboxJobRecord {
|
||||
id: job_id,
|
||||
task: "Event test".to_string(),
|
||||
status: "running".to_string(),
|
||||
user_id: "user1".to_string(),
|
||||
project_dir: "/workspace".to_string(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
started_at: Some(chrono::Utc::now()),
|
||||
completed_at: None,
|
||||
credential_grants_json: "[]".to_string(),
|
||||
};
|
||||
db.save_sandbox_job(&job).await.expect("save job");
|
||||
|
||||
// Save events
|
||||
db.save_job_event(
|
||||
job_id,
|
||||
"tool_call",
|
||||
&serde_json::json!({"tool": "shell", "args": {"command": "ls"}}),
|
||||
)
|
||||
.await
|
||||
.expect("save event 1");
|
||||
|
||||
db.save_job_event(
|
||||
job_id,
|
||||
"tool_result",
|
||||
&serde_json::json!({"output": "file1.txt\nfile2.txt"}),
|
||||
)
|
||||
.await
|
||||
.expect("save event 2");
|
||||
|
||||
db.save_job_event(
|
||||
job_id,
|
||||
"llm_response",
|
||||
&serde_json::json!({"content": "Found 2 files"}),
|
||||
)
|
||||
.await
|
||||
.expect("save event 3");
|
||||
|
||||
// List all events
|
||||
let events = db.list_job_events(job_id, None).await.expect("list events");
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
// List with limit
|
||||
let events = db
|
||||
.list_job_events(job_id, Some(2))
|
||||
.await
|
||||
.expect("list events limited");
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_estimation_snapshot_round_trip() {
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
// Create a job first
|
||||
let job_ctx = crate::context::JobContext::with_user("user1", "Estimate test", "testing");
|
||||
let job_id = job_ctx.job_id;
|
||||
db.save_job(&job_ctx).await.expect("save job");
|
||||
|
||||
// Save estimation snapshot
|
||||
let snap_id = db
|
||||
.save_estimation_snapshot(
|
||||
job_id,
|
||||
"code_generation",
|
||||
&["shell".to_string(), "write_file".to_string()],
|
||||
Decimal::new(50, 2), // 0.50
|
||||
120,
|
||||
Decimal::new(500, 2), // 5.00
|
||||
)
|
||||
.await
|
||||
.expect("save snapshot");
|
||||
assert!(!snap_id.is_nil());
|
||||
|
||||
// Update with actuals
|
||||
db.update_estimation_actuals(
|
||||
snap_id,
|
||||
Decimal::new(45, 2), // 0.45
|
||||
110,
|
||||
Some(Decimal::new(600, 2)), // 6.00
|
||||
)
|
||||
.await
|
||||
.expect("update actuals");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +496,61 @@ impl Tool for ToolRemoveTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── extension_info ────────────────────────────────────────────────────
|
||||
|
||||
pub struct ExtensionInfoTool {
|
||||
manager: Arc<ExtensionManager>,
|
||||
}
|
||||
|
||||
impl ExtensionInfoTool {
|
||||
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
||||
Self { manager }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ExtensionInfoTool {
|
||||
fn name(&self) -> &str {
|
||||
"extension_info"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Show detailed information about an installed extension, including version \
|
||||
and WIT version compatibility."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Extension name to get info about"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let info = self
|
||||
.manager
|
||||
.extension_info(name)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
Ok(ToolOutput::success(info, start.elapsed()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -588,6 +643,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_info_schema() {
|
||||
let tool = ExtensionInfoTool {
|
||||
manager: test_manager_stub(),
|
||||
};
|
||||
assert_eq!(tool.name(), "extension_info");
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"].get("name").is_some());
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.iter().any(|v| v.as_str() == Some("name")));
|
||||
}
|
||||
|
||||
/// Create a stub manager for schema tests (these don't call execute).
|
||||
fn test_manager_stub() -> Arc<ExtensionManager> {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
|
||||
@@ -998,4 +998,44 @@ mod tests {
|
||||
let params = serde_json::json!({"method": "GET"});
|
||||
assert_eq!(extract_host_from_params(¶ms), None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_multi_thread_no_panic() {
|
||||
use crate::secrets::CredentialMapping;
|
||||
use crate::tools::wasm::SharedCredentialRegistry;
|
||||
|
||||
// Test with credential registry (uses std::sync::RwLock - should be safe)
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
registry.add_mappings(vec![CredentialMapping::bearer("test_key", "api.test.com")]);
|
||||
|
||||
let tool = HttpTool::new().with_credentials(
|
||||
registry,
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
))),
|
||||
);
|
||||
|
||||
// These calls should not panic in multi-thread runtime
|
||||
let params_no_auth = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/data"
|
||||
});
|
||||
let _ = tool.requires_approval(¶ms_no_auth);
|
||||
|
||||
let params_with_cred = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://api.test.com/v1/models"
|
||||
});
|
||||
let _ = tool.requires_approval(¶ms_with_cred);
|
||||
|
||||
let params_with_auth = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com",
|
||||
"headers": {"Authorization": "Bearer token"}
|
||||
});
|
||||
let _ = tool.requires_approval(¶ms_with_auth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -533,41 +533,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: requires_approval() is a sync method called from async context.
|
||||
/// With tokio::sync::RwLock, this would panic with:
|
||||
/// "Cannot block the current thread from within a runtime"
|
||||
/// because blocking_read() cannot be called inside an async runtime.
|
||||
/// With std::sync::RwLock, it works correctly since std locks are safe
|
||||
/// for short-held locks in sync methods called from async contexts.
|
||||
#[tokio::test]
|
||||
async fn requires_approval_works_from_async_context() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
// ── Multi-thread runtime safety tests ─────────────────────────────
|
||||
|
||||
// Set context asynchronously (simulating real usage pattern)
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_no_channel_multi_thread() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
// No channel set, no channel param - should not panic in multi-thread runtime
|
||||
let result = tool.requires_approval(&serde_json::json!({"content": "hello"}));
|
||||
assert_eq!(result, ApprovalRequirement::UnlessAutoApproved);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_with_context_multi_thread() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
tool.set_context(Some("signal".to_string()), Some("+1234567890".to_string()))
|
||||
.await;
|
||||
|
||||
// Call requires_approval (sync method) from async context.
|
||||
// This is the critical test: with tokio::sync::RwLock::blocking_read(),
|
||||
// this would panic. With std::sync::RwLock::read(), it works.
|
||||
let approval = tool.requires_approval(&serde_json::json!({
|
||||
// No channel param - uses default, less risky
|
||||
let result = tool.requires_approval(&serde_json::json!({"content": "hello"}));
|
||||
assert_eq!(result, ApprovalRequirement::UnlessAutoApproved);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_cross_channel_multi_thread() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
tool.set_context(Some("signal".to_string()), Some("+1234567890".to_string()))
|
||||
.await;
|
||||
|
||||
// Different channel than default requires approval
|
||||
let result = tool.requires_approval(&serde_json::json!({
|
||||
"content": "hello",
|
||||
"channel": "telegram"
|
||||
}));
|
||||
// Different channel from default -> Always
|
||||
assert!(matches!(approval, ApprovalRequirement::Always));
|
||||
assert_eq!(result, ApprovalRequirement::Always);
|
||||
}
|
||||
|
||||
// No channel specified (uses default) -> UnlessAutoApproved
|
||||
let approval = tool.requires_approval(&serde_json::json!({
|
||||
"content": "hello"
|
||||
}));
|
||||
assert!(matches!(approval, ApprovalRequirement::UnlessAutoApproved));
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_same_channel_explicit_multi_thread() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
tool.set_context(Some("signal".to_string()), Some("+1234567890".to_string()))
|
||||
.await;
|
||||
|
||||
// Explicit channel (even if same as default) -> Always
|
||||
let approval = tool.requires_approval(&serde_json::json!({
|
||||
// Explicit channel that matches default still returns Always
|
||||
// (existing behavior: any explicit channel param triggers Always)
|
||||
let result = tool.requires_approval(&serde_json::json!({
|
||||
"content": "hello",
|
||||
"channel": "signal"
|
||||
}));
|
||||
assert!(matches!(approval, ApprovalRequirement::Always));
|
||||
assert_eq!(result, ApprovalRequirement::Always);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -18,10 +21,14 @@ mod time;
|
||||
|
||||
pub use echo::EchoTool;
|
||||
pub use extension_tools::{
|
||||
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
||||
ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
|
||||
ToolRemoveTool, ToolSearchTool,
|
||||
};
|
||||
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,
|
||||
|
||||
+78
-7
@@ -16,11 +16,12 @@ use crate::skills::catalog::SkillCatalog;
|
||||
use crate::skills::registry::SkillRegistry;
|
||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||
use crate::tools::builtin::{
|
||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, 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,
|
||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
||||
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};
|
||||
@@ -70,6 +71,9 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
"message",
|
||||
"web_fetch",
|
||||
"restart",
|
||||
"image_generate",
|
||||
"image_edit",
|
||||
"image_analyze",
|
||||
];
|
||||
|
||||
/// Registry of available tools.
|
||||
@@ -301,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.
|
||||
@@ -386,8 +418,9 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(ToolAuthTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ToolActivateTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ToolListTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ToolRemoveTool::new(manager)));
|
||||
tracing::info!("Registered 6 extension management tools");
|
||||
self.register_sync(Arc::new(ToolRemoveTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ExtensionInfoTool::new(manager)));
|
||||
tracing::info!("Registered 7 extension management tools");
|
||||
}
|
||||
|
||||
/// Register skill management tools (list, search, install, remove).
|
||||
@@ -763,6 +796,44 @@ mod tests {
|
||||
assert_ne!(desc, "EVIL SHADOW");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_register_and_read_no_panic() {
|
||||
use std::sync::Arc as StdArc;
|
||||
|
||||
let registry = StdArc::new(ToolRegistry::new());
|
||||
registry.register_builtin_tools();
|
||||
|
||||
// Spawn concurrent readers and check they don't panic
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Readers
|
||||
for _ in 0..10 {
|
||||
let reg = StdArc::clone(®istry);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let tools = reg.all().await;
|
||||
assert!(!tools.is_empty());
|
||||
let names = reg.list().await;
|
||||
assert!(!names.is_empty());
|
||||
let _ = reg.get("echo").await;
|
||||
let _ = reg.has("echo").await;
|
||||
let _ = reg.tool_definitions().await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Concurrent register attempts (will be rejected as shadowing)
|
||||
for _ in 0..5 {
|
||||
let reg = StdArc::clone(®istry);
|
||||
handles.push(tokio::spawn(async move {
|
||||
// This will be rejected (echo is protected) but should not panic
|
||||
reg.register(Arc::new(EchoTool)).await;
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.expect("task should not panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_definitions_sorted_alphabetically() {
|
||||
// Create tools with names that would NOT be alphabetical if inserted in this order.
|
||||
|
||||
@@ -41,6 +41,14 @@ use crate::tools::wasm::{
|
||||
/// Root schema for a capabilities JSON file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CapabilitiesFile {
|
||||
/// Extension version (semver).
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
|
||||
/// WIT interface version this extension was compiled against (semver).
|
||||
#[serde(default)]
|
||||
pub wit_version: Option<String>,
|
||||
|
||||
/// HTTP request capability.
|
||||
#[serde(default)]
|
||||
pub http: Option<HttpCapabilitySchema>,
|
||||
|
||||
+106
-1
@@ -72,6 +72,9 @@ pub enum WasmLoadError {
|
||||
|
||||
#[error("Invalid tool name: {0}")]
|
||||
InvalidName(String),
|
||||
|
||||
#[error("WIT version mismatch: {0}")]
|
||||
WitVersionMismatch(String),
|
||||
}
|
||||
|
||||
/// Loads WASM tools from files or storage into the registry.
|
||||
@@ -127,6 +130,14 @@ impl WasmToolLoader {
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
|
||||
// Check WIT version compatibility
|
||||
check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_TOOL_VERSION,
|
||||
)?;
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
(caps, oauth)
|
||||
@@ -310,6 +321,61 @@ impl WasmToolLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a declared WIT version is compatible with the host WIT version.
|
||||
///
|
||||
/// Compatibility rules (semver):
|
||||
/// - Same major version required (0.x is special: same minor required)
|
||||
/// - Extension WIT version must not be greater than host version
|
||||
///
|
||||
/// If `declared` is `None`, the check is skipped (pre-versioning extension).
|
||||
pub(crate) fn check_wit_version_compat(
|
||||
name: &str,
|
||||
declared: Option<&str>,
|
||||
host_version: &str,
|
||||
) -> Result<(), WasmLoadError> {
|
||||
let Some(declared_str) = declared else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let declared = semver::Version::parse(declared_str).map_err(|e| {
|
||||
WasmLoadError::WitVersionMismatch(format!(
|
||||
"Extension '{name}' has invalid wit_version '{declared_str}': {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let host = semver::Version::parse(host_version).map_err(|e| {
|
||||
WasmLoadError::WitVersionMismatch(format!(
|
||||
"Host WIT version '{host_version}' is invalid: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Major version must match
|
||||
if declared.major != host.major {
|
||||
return Err(WasmLoadError::WitVersionMismatch(format!(
|
||||
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
|
||||
Major version mismatch — rebuild the extension."
|
||||
)));
|
||||
}
|
||||
|
||||
// For 0.x versions, minor must also match (semver: 0.x.y has no compatibility guarantees)
|
||||
if declared.major == 0 && declared.minor != host.minor {
|
||||
return Err(WasmLoadError::WitVersionMismatch(format!(
|
||||
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
|
||||
Rebuild the extension against the current WIT."
|
||||
)));
|
||||
}
|
||||
|
||||
// Extension cannot be newer than host
|
||||
if declared > host {
|
||||
return Err(WasmLoadError::WitVersionMismatch(format!(
|
||||
"Extension '{name}' compiled against WIT {declared}, but host only supports WIT {host}. \
|
||||
Update the host or rebuild with an older WIT."
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract OAuth refresh configuration from a parsed capabilities file.
|
||||
///
|
||||
/// Returns `None` if there's no `auth.oauth` section or if the client_id
|
||||
@@ -615,7 +681,46 @@ mod tests {
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::tools::wasm::loader::{WasmLoadError, discover_tools};
|
||||
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_none_is_ok() {
|
||||
// Pre-versioning extensions (no wit_version declared) should always pass
|
||||
assert!(check_wit_version_compat("test", None, "0.2.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_exact_match() {
|
||||
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_patch_older_ok() {
|
||||
// Extension on older patch of same minor is compatible
|
||||
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_minor_mismatch_0x() {
|
||||
// For 0.x, different minor is breaking
|
||||
assert!(check_wit_version_compat("test", Some("0.1.0"), "0.2.0").is_err());
|
||||
assert!(check_wit_version_compat("test", Some("0.3.0"), "0.2.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_major_mismatch() {
|
||||
assert!(check_wit_version_compat("test", Some("1.0.0"), "2.0.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_extension_newer_than_host() {
|
||||
assert!(check_wit_version_compat("test", Some("0.2.1"), "0.2.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_version_compat_invalid_version() {
|
||||
assert!(check_wit_version_compat("test", Some("not-a-version"), "0.2.0").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_discover_tools_empty_dir() {
|
||||
|
||||
+12
-3
@@ -73,6 +73,15 @@
|
||||
//! let output = tool.execute(serde_json::json!({"input": "test"}), &ctx).await?;
|
||||
//! ```
|
||||
|
||||
/// Host WIT version for tool extensions.
|
||||
///
|
||||
/// Extensions declaring a `wit_version` in their capabilities file are checked
|
||||
/// against this at load time: same major, not greater than host.
|
||||
pub const WIT_TOOL_VERSION: &str = "0.2.0";
|
||||
|
||||
/// Host WIT version for channel extensions.
|
||||
pub const WIT_CHANNEL_VERSION: &str = "0.2.0";
|
||||
|
||||
mod allowlist;
|
||||
mod capabilities;
|
||||
mod capabilities_schema;
|
||||
@@ -80,10 +89,10 @@ pub(crate) mod credential_injector;
|
||||
mod error;
|
||||
mod host;
|
||||
mod limits;
|
||||
mod loader;
|
||||
pub(crate) mod loader;
|
||||
mod rate_limiter;
|
||||
mod runtime;
|
||||
mod storage;
|
||||
pub(crate) mod storage;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
@@ -93,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
|
||||
|
||||
+65
-59
@@ -100,6 +100,7 @@ pub struct StoredWasmTool {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub wit_version: String,
|
||||
pub description: String,
|
||||
pub parameters_schema: serde_json::Value,
|
||||
pub source_url: Option<String>,
|
||||
@@ -244,6 +245,7 @@ pub struct StoreToolParams {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub wit_version: String,
|
||||
pub description: String,
|
||||
pub wasm_binary: Vec<u8>,
|
||||
pub parameters_schema: serde_json::Value,
|
||||
@@ -280,7 +282,7 @@ impl PostgresWasmToolStore {
|
||||
#[async_trait]
|
||||
impl WasmToolStore for PostgresWasmToolStore {
|
||||
async fn store(&self, params: StoreToolParams) -> Result<StoredWasmTool, WasmStorageError> {
|
||||
let client = self
|
||||
let mut client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
@@ -290,22 +292,29 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
let id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
let row = client
|
||||
// Wrap delete + insert in a transaction for atomicity
|
||||
let tx = client
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
// Delete any existing version for this (user_id, name) — upgrade-in-place
|
||||
tx.execute(
|
||||
"DELETE FROM wasm_tools WHERE user_id = $1 AND name = $2",
|
||||
&[¶ms.user_id, ¶ms.name],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
let row = tx
|
||||
.query_one(
|
||||
r#"
|
||||
INSERT INTO wasm_tools (
|
||||
id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'active', $11, $11)
|
||||
ON CONFLICT (user_id, name, version) DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
wasm_binary = EXCLUDED.wasm_binary,
|
||||
binary_hash = EXCLUDED.binary_hash,
|
||||
parameters_schema = EXCLUDED.parameters_schema,
|
||||
source_url = EXCLUDED.source_url,
|
||||
updated_at = NOW()
|
||||
RETURNING id, user_id, name, version, description, parameters_schema,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12, $12)
|
||||
RETURNING id, user_id, name, version, wit_version, description, parameters_schema,
|
||||
source_url, trust_level, status, created_at, updated_at
|
||||
"#,
|
||||
&[
|
||||
@@ -313,6 +322,7 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
¶ms.user_id,
|
||||
¶ms.name,
|
||||
¶ms.version,
|
||||
¶ms.wit_version,
|
||||
¶ms.description,
|
||||
¶ms.wasm_binary,
|
||||
&binary_hash,
|
||||
@@ -325,7 +335,13 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
row_to_tool(&row)
|
||||
let tool = row_to_tool(&row)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<StoredWasmTool, WasmStorageError> {
|
||||
@@ -338,12 +354,10 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
let row = client
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
SELECT id, user_id, name, version, wit_version, description, parameters_schema,
|
||||
source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = $1 AND name = $2 AND status = 'active'
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
&[&user_id, &name],
|
||||
)
|
||||
@@ -377,12 +391,10 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
let row = client
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
SELECT id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = $1 AND name = $2 AND status = 'active'
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
&[&user_id, &name],
|
||||
)
|
||||
@@ -482,11 +494,11 @@ impl WasmToolStore for PostgresWasmToolStore {
|
||||
let rows = client
|
||||
.query(
|
||||
r#"
|
||||
SELECT DISTINCT ON (name) id, user_id, name, version, description,
|
||||
SELECT id, user_id, name, version, wit_version, description,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = $1
|
||||
ORDER BY name, version DESC
|
||||
ORDER BY name
|
||||
"#,
|
||||
&[&user_id],
|
||||
)
|
||||
@@ -552,6 +564,7 @@ fn row_to_tool(row: &tokio_postgres::Row) -> Result<StoredWasmTool, WasmStorageE
|
||||
user_id: row.get("user_id"),
|
||||
name: row.get("name"),
|
||||
version: row.get("version"),
|
||||
wit_version: row.get("wit_version"),
|
||||
description: row.get("description"),
|
||||
parameters_schema: row.get("parameters_schema"),
|
||||
source_url: row.get("source_url"),
|
||||
@@ -605,33 +618,35 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
let schema_str = serde_json::to_string(¶ms.parameters_schema)
|
||||
.map_err(|e| WasmStorageError::InvalidData(e.to_string()))?;
|
||||
|
||||
// Wrap INSERT + read-back in a transaction to prevent TOCTOU races
|
||||
// Wrap delete + INSERT + read-back in a transaction
|
||||
let conn = self.connect().await?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
// Delete any existing version for this (user_id, name) — upgrade-in-place
|
||||
tx.execute(
|
||||
"DELETE FROM wasm_tools WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![params.user_id.as_str(), params.name.as_str()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO wasm_tools (
|
||||
id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?11)
|
||||
ON CONFLICT (user_id, name, version) DO UPDATE SET
|
||||
description = excluded.description,
|
||||
wasm_binary = excluded.wasm_binary,
|
||||
binary_hash = excluded.binary_hash,
|
||||
parameters_schema = excluded.parameters_schema,
|
||||
source_url = excluded.source_url,
|
||||
updated_at = ?11
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'active', ?12, ?12)
|
||||
"#,
|
||||
libsql::params![
|
||||
id.to_string(),
|
||||
params.user_id.as_str(),
|
||||
params.name.as_str(),
|
||||
params.version.as_str(),
|
||||
params.wit_version.as_str(),
|
||||
params.description.as_str(),
|
||||
libsql::Value::Blob(params.wasm_binary),
|
||||
libsql::Value::Blob(binary_hash),
|
||||
@@ -648,12 +663,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
let mut rows = tx
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
SELECT id, user_id, name, version, wit_version, description, parameters_schema,
|
||||
source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = ?1 AND name = ?2
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
libsql::params![params.user_id.as_str(), params.name.as_str()],
|
||||
)
|
||||
@@ -682,12 +695,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
SELECT id, user_id, name, version, wit_version, description, parameters_schema,
|
||||
source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = ?1 AND name = ?2 AND status = 'active'
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
libsql::params![user_id, name],
|
||||
)
|
||||
@@ -720,12 +731,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
SELECT id, user_id, name, version, wit_version, description, wasm_binary, binary_hash,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = ?1 AND name = ?2 AND status = 'active'
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
libsql::params![user_id, name],
|
||||
)
|
||||
@@ -739,10 +748,10 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
{
|
||||
Some(row) => {
|
||||
let wasm_binary: Vec<u8> = row
|
||||
.get(5)
|
||||
.get(6)
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
let binary_hash: Vec<u8> = row
|
||||
.get(6)
|
||||
.get(7)
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
if !verify_binary_integrity(&wasm_binary, &binary_hash) {
|
||||
@@ -844,21 +853,14 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmTool>, WasmStorageError> {
|
||||
// SQLite doesn't have DISTINCT ON, so we use a subquery to get latest version per name
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
SELECT id, user_id, name, version, wit_version, description, parameters_schema,
|
||||
source_url, trust_level, status, created_at, updated_at
|
||||
FROM wasm_tools
|
||||
WHERE user_id = ?1
|
||||
AND rowid IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM wasm_tools
|
||||
WHERE user_id = ?1
|
||||
GROUP BY name
|
||||
)
|
||||
ORDER BY name
|
||||
"#,
|
||||
libsql::params![user_id],
|
||||
@@ -941,22 +943,22 @@ fn libsql_wasm_parse_ts(s: &str) -> Result<DateTime<Utc>, WasmStorageError> {
|
||||
}
|
||||
|
||||
/// Parse a tool row with standard column order (no binary columns).
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), description(4),
|
||||
/// parameters_schema(5), source_url(6), trust_level(7), status(8),
|
||||
/// created_at(9), updated_at(10)
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5),
|
||||
/// parameters_schema(6), source_url(7), trust_level(8), status(9),
|
||||
/// created_at(10), updated_at(11)
|
||||
#[cfg(feature = "libsql")]
|
||||
fn libsql_row_to_tool(row: &libsql::Row) -> Result<StoredWasmTool, WasmStorageError> {
|
||||
libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
||||
libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
|
||||
}
|
||||
|
||||
/// Parse a tool row when binary columns are present (get_with_binary query).
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), description(4),
|
||||
/// wasm_binary(5), binary_hash(6),
|
||||
/// parameters_schema(7), source_url(8), trust_level(9), status(10),
|
||||
/// created_at(11), updated_at(12)
|
||||
/// Columns: id(0), user_id(1), name(2), version(3), wit_version(4), description(5),
|
||||
/// wasm_binary(6), binary_hash(7),
|
||||
/// parameters_schema(8), source_url(9), trust_level(10), status(11),
|
||||
/// created_at(12), updated_at(13)
|
||||
#[cfg(feature = "libsql")]
|
||||
fn libsql_row_to_tool_with_offset(row: &libsql::Row) -> Result<StoredWasmTool, WasmStorageError> {
|
||||
libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12)
|
||||
libsql_row_to_tool_at(row, 0, 1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13)
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
@@ -967,6 +969,7 @@ fn libsql_row_to_tool_at(
|
||||
user_id_idx: i32,
|
||||
name_idx: i32,
|
||||
version_idx: i32,
|
||||
wit_version_idx: i32,
|
||||
description_idx: i32,
|
||||
schema_idx: i32,
|
||||
source_url_idx: i32,
|
||||
@@ -1007,6 +1010,9 @@ fn libsql_row_to_tool_at(
|
||||
version: row
|
||||
.get(version_idx)
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?,
|
||||
wit_version: row
|
||||
.get(wit_version_idx)
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?,
|
||||
description: row
|
||||
.get(description_idx)
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?,
|
||||
|
||||
@@ -589,8 +589,20 @@ impl WasmToolWrapper {
|
||||
Self::add_host_functions(&mut linker)?;
|
||||
|
||||
// Instantiate using the generated bindings
|
||||
let instance = SandboxedTool::instantiate(&mut store, &component, &linker)
|
||||
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
|
||||
let instance =
|
||||
SandboxedTool::instantiate(&mut store, &component, &linker).map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("near:agent") || msg.contains("import") {
|
||||
WasmError::InstantiationFailed(format!(
|
||||
"{msg}. This usually means the extension was compiled against \
|
||||
a different WIT version than the host supports. \
|
||||
Rebuild the extension against the current WIT (host: {}).",
|
||||
crate::tools::wasm::WIT_TOOL_VERSION
|
||||
))
|
||||
} else {
|
||||
WasmError::InstantiationFailed(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Coerce string-encoded values to their schema-declared types.
|
||||
// LLMs frequently pass numeric values as strings (e.g. "5" instead of 5).
|
||||
|
||||
+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);
|
||||
|
||||
@@ -52,6 +52,10 @@ Then Playwright drives a headless Chromium browser against the gateway, making D
|
||||
| `test_connection.py` | Auth, tab navigation, connection status |
|
||||
| `test_chat.py` | Send message, SSE streaming, response rendering |
|
||||
| `test_skills.py` | ClawHub search, skill install/remove |
|
||||
| `test_tool_approval.py` | Tool approval overlay (approve, deny, always, params toggle) |
|
||||
| `test_sse_reconnect.py` | SSE reconnection handling |
|
||||
| `test_html_injection.py` | HTML injection security |
|
||||
| `test_extensions.py` | Extensions tab: install, remove, configure, OAuth, auth card, activate |
|
||||
|
||||
## Adding new scenarios
|
||||
|
||||
@@ -59,3 +63,106 @@ Then Playwright drives a headless Chromium browser against the gateway, making D
|
||||
2. Use the `page` fixture for a fresh browser page
|
||||
3. Use selectors from `helpers.py` (update `SEL` dict if new elements are needed)
|
||||
4. Keep tests deterministic -- use the mock LLM, not real providers
|
||||
|
||||
## Mocking API responses with `page.route()`
|
||||
|
||||
For tabs that depend on external data (extensions, jobs, memory, routines), use
|
||||
Playwright's `page.route()` to intercept the browser's HTTP requests to the
|
||||
ironclaw gateway and return deterministic fixture JSON. This avoids needing
|
||||
real installed binaries, live external services, or complex database setup.
|
||||
|
||||
### Basic pattern
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
async def test_something(page):
|
||||
# 1. Set up route intercepts BEFORE navigation triggers the fetch
|
||||
# Always use async def handlers — route.fulfill() is a coroutine and must be awaited.
|
||||
async def handle_tools(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"tools": [{"name": "echo", "description": "Echo"}]}),
|
||||
)
|
||||
|
||||
await page.route("**/api/extensions/tools", handle_tools)
|
||||
|
||||
# 2. Navigate / interact to trigger the fetch
|
||||
await page.locator('.tab-bar button[data-tab="extensions"]').click()
|
||||
|
||||
# 3. Assert on the rendered DOM
|
||||
rows = page.locator("#tools-tbody tr")
|
||||
assert await rows.count() == 1
|
||||
```
|
||||
|
||||
### Matching only the exact path
|
||||
|
||||
`**/api/extensions` matches `http://host/api/extensions` but NOT sub-paths
|
||||
like `http://host/api/extensions/install`. For the bare list endpoint, add
|
||||
a check inside the handler:
|
||||
|
||||
```python
|
||||
async def handle_ext_list(route):
|
||||
path = route.request.url.split("?")[0]
|
||||
if path.endswith("/api/extensions"):
|
||||
await route.fulfill(json={"extensions": []})
|
||||
else:
|
||||
await route.continue_() # Let sub-paths through to the real server
|
||||
|
||||
await page.route("**/api/extensions*", handle_ext_list)
|
||||
```
|
||||
|
||||
### Mocking method-specific behaviour (GET vs POST)
|
||||
|
||||
```python
|
||||
async def handle_setup(route):
|
||||
if route.request.method == "GET":
|
||||
await route.fulfill(json={"secrets": [...]})
|
||||
else: # POST
|
||||
await route.fulfill(json={"success": True})
|
||||
|
||||
await page.route("**/api/extensions/my-ext/setup", handle_setup)
|
||||
```
|
||||
|
||||
### Counting calls (for reload tests)
|
||||
|
||||
```python
|
||||
calls = []
|
||||
|
||||
async def counting_handler(route):
|
||||
calls.append(1)
|
||||
await route.fulfill(json={"extensions": []})
|
||||
|
||||
await page.route("**/api/extensions", counting_handler)
|
||||
# ... interact ...
|
||||
assert len(calls) == 2 # called twice (initial + after some action)
|
||||
```
|
||||
|
||||
### Applying the pattern to other tabs
|
||||
|
||||
| Tab | Key API endpoints to mock |
|
||||
|-----|--------------------------|
|
||||
| **Jobs** | `/api/jobs`, `/api/jobs/{id}`, `/api/jobs/{id}/events` |
|
||||
| **Memory** | `/api/memory/search`, `/api/memory/tree`, `/api/memory/read` |
|
||||
| **Routines** | `/api/routines`, `/api/routines/{id}/runs` |
|
||||
|
||||
### Injecting state directly via `page.evaluate()`
|
||||
|
||||
For purely client-side UI (components rendered entirely in JS without API calls),
|
||||
call the JavaScript function directly to skip the network layer entirely:
|
||||
|
||||
```python
|
||||
# Show an approval card without needing a real tool execution
|
||||
await page.evaluate("""
|
||||
showApproval({
|
||||
request_id: 'test-001',
|
||||
thread_id: currentThreadId,
|
||||
tool_name: 'shell',
|
||||
description: 'Run something',
|
||||
})
|
||||
""")
|
||||
```
|
||||
|
||||
This is the pattern used in `test_tool_approval.py` and parts of
|
||||
`test_extensions.py` (auth card, configure modal).
|
||||
|
||||
@@ -43,6 +43,58 @@ SEL = {
|
||||
"approval_always_btn": ".approval-actions button.always",
|
||||
"approval_deny_btn": ".approval-actions button.deny",
|
||||
"approval_resolved": ".approval-resolved",
|
||||
# Extensions tab – sections
|
||||
"extensions_list": "#extensions-list",
|
||||
"available_wasm_list": "#available-wasm-list",
|
||||
"mcp_servers_list": "#mcp-servers-list",
|
||||
"tools_tbody": "#tools-tbody",
|
||||
"tools_empty": "#tools-empty",
|
||||
# Extensions tab – cards
|
||||
"ext_card_installed": "#extensions-list .ext-card",
|
||||
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
|
||||
"ext_card_mcp": "#mcp-servers-list .ext-card",
|
||||
"ext_name": ".ext-name",
|
||||
"ext_kind": ".ext-kind",
|
||||
"ext_auth_dot": ".ext-auth-dot",
|
||||
"ext_auth_dot_authed": ".ext-auth-dot.authed",
|
||||
"ext_auth_dot_unauthed": ".ext-auth-dot.unauthed",
|
||||
"ext_active_label": ".ext-active-label",
|
||||
"ext_pairing_label": ".ext-pairing-label",
|
||||
"ext_error": ".ext-error",
|
||||
"ext_tools": ".ext-tools",
|
||||
# Extensions tab – action buttons
|
||||
"ext_install_btn": ".btn-ext.install",
|
||||
"ext_remove_btn": ".btn-ext.remove",
|
||||
"ext_activate_btn": ".btn-ext.activate",
|
||||
"ext_configure_btn": ".btn-ext.configure",
|
||||
# Configure modal
|
||||
"configure_overlay": ".configure-overlay",
|
||||
"configure_modal": ".configure-modal",
|
||||
"configure_field": ".configure-field",
|
||||
"configure_input": ".configure-modal input[type='password']",
|
||||
"configure_save_btn": ".configure-actions button.btn-ext.activate",
|
||||
"configure_cancel_btn": ".configure-actions button.btn-ext.remove",
|
||||
"field_provided": ".field-provided",
|
||||
"field_autogen": ".field-autogen",
|
||||
"field_optional": ".field-optional",
|
||||
# Auth card (SSE-triggered, injected into chat-messages)
|
||||
"auth_card": ".auth-card",
|
||||
"auth_header": ".auth-header",
|
||||
"auth_instructions": ".auth-instructions",
|
||||
"auth_oauth_btn": ".auth-oauth",
|
||||
"auth_token_input": ".auth-token-input input",
|
||||
"auth_submit_btn": ".auth-submit",
|
||||
"auth_cancel_btn": ".auth-cancel",
|
||||
"auth_error": ".auth-error",
|
||||
# WASM channel progress stepper
|
||||
"ext_stepper": ".ext-stepper",
|
||||
"stepper_step": ".stepper-step",
|
||||
"stepper_circle": ".stepper-circle",
|
||||
# Toast notifications
|
||||
"toast": ".toast",
|
||||
"toast_success": ".toast.toast-success",
|
||||
"toast_error": ".toast.toast-error",
|
||||
"toast_info": ".toast.toast-info",
|
||||
}
|
||||
|
||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
//! E2E trace tests: builtin tool coverage (#573).
|
||||
//!
|
||||
//! Covers time (parse, diff, invalid), routine (create, list, update, delete,
|
||||
//! history), job (create, status, list, cancel), and HTTP replay.
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::LlmTrace;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: time_parse_and_diff
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn time_parse_and_diff() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/time_parse_diff.json"
|
||||
))
|
||||
.expect("failed to load time_parse_diff.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Parse a time and compute a diff").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// Time tool should have been called twice (parse + diff).
|
||||
let started = rig.tool_calls_started();
|
||||
let time_count = started.iter().filter(|n| n.as_str() == "time").count();
|
||||
assert!(
|
||||
time_count >= 2,
|
||||
"Expected >= 2 time tool calls, got {time_count}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: time_parse_invalid
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn time_parse_invalid() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/time_parse_invalid.json"
|
||||
))
|
||||
.expect("failed to load time_parse_invalid.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Parse an invalid timestamp").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// The time tool call should have failed (invalid timestamp).
|
||||
let completed = rig.tool_calls_completed();
|
||||
let time_results: Vec<_> = completed
|
||||
.iter()
|
||||
.filter(|(name, _)| name == "time")
|
||||
.collect();
|
||||
assert!(!time_results.is_empty(), "Expected time tool to be called");
|
||||
assert!(
|
||||
time_results.iter().any(|(_, ok)| !ok),
|
||||
"Expected at least one failed time call: {time_results:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: routine_create_list
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_create_list() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_create_list.json"
|
||||
))
|
||||
.expect("failed to load routine_create_list.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a daily routine and list all routines")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// Both routine_create and routine_list should have succeeded.
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "routine_create" && *ok),
|
||||
"routine_create should succeed: {completed:?}"
|
||||
);
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "routine_list" && *ok),
|
||||
"routine_list should succeed: {completed:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 4: routine_update_delete
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_update_delete() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_update_delete.json"
|
||||
))
|
||||
.expect("failed to load routine_update_delete.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create, update, and delete a routine")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let started = rig.tool_calls_started();
|
||||
assert!(
|
||||
started.contains(&"routine_create".to_string()),
|
||||
"routine_create not started"
|
||||
);
|
||||
assert!(
|
||||
started.contains(&"routine_update".to_string()),
|
||||
"routine_update not started"
|
||||
);
|
||||
assert!(
|
||||
started.contains(&"routine_delete".to_string()),
|
||||
"routine_delete not started"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 5: routine_history
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_history() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_history.json"
|
||||
))
|
||||
.expect("failed to load routine_history.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a routine and check its history")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let started = rig.tool_calls_started();
|
||||
assert!(
|
||||
started.contains(&"routine_create".to_string()),
|
||||
"routine_create missing"
|
||||
);
|
||||
assert!(
|
||||
started.contains(&"routine_history".to_string()),
|
||||
"routine_history missing"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 6: job_create_status
|
||||
// -----------------------------------------------------------------------
|
||||
// Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from
|
||||
// create_job's result into job_status's arguments.
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_create_status() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/job_create_status.json"
|
||||
))
|
||||
.expect("failed to load job_create_status.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a job and check its status").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// Both tools should have succeeded.
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
||||
"create_job should succeed: {completed:?}"
|
||||
);
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "job_status" && *ok),
|
||||
"job_status should succeed: {completed:?}"
|
||||
);
|
||||
|
||||
// Verify tool results contain expected content.
|
||||
let results = rig.tool_results();
|
||||
let create_result = results
|
||||
.iter()
|
||||
.find(|(n, _)| n == "create_job")
|
||||
.expect("create_job result missing");
|
||||
assert!(
|
||||
create_result.1.contains("job_id"),
|
||||
"create_job should return a job_id: {:?}",
|
||||
create_result.1
|
||||
);
|
||||
let status_result = results
|
||||
.iter()
|
||||
.find(|(n, _)| n == "job_status")
|
||||
.expect("job_status result missing");
|
||||
assert!(
|
||||
status_result.1.contains("Test analysis job"),
|
||||
"job_status should return the job title: {:?}",
|
||||
status_result.1
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 7: job_list_cancel
|
||||
// -----------------------------------------------------------------------
|
||||
// Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from
|
||||
// create_job into cancel_job.
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_list_cancel() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/job_list_cancel.json"
|
||||
))
|
||||
.expect("failed to load job_list_cancel.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a job, list jobs, then cancel it")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// All three tools should have succeeded.
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "create_job" && *ok),
|
||||
"create_job should succeed: {completed:?}"
|
||||
);
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "list_jobs" && *ok),
|
||||
"list_jobs should succeed: {completed:?}"
|
||||
);
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "cancel_job" && *ok),
|
||||
"cancel_job should succeed: {completed:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8: http_get_with_replay
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_get_with_replay() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/http_get_replay.json"
|
||||
))
|
||||
.expect("failed to load http_get_replay.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Make an http GET request").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
// HTTP tool should have succeeded with the replayed exchange.
|
||||
let completed = rig.tool_calls_completed();
|
||||
assert!(
|
||||
completed.iter().any(|(n, ok)| n == "http" && *ok),
|
||||
"http tool should succeed: {completed:?}"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user