Compare commits

..
Author SHA1 Message Date
Henry ParkandClaude Opus 4.6 12508253ba refactor(ci): PR-based Claude review instead of batch extraction [skip-regression-check]
Restructure staging-ci to create the promotion PR first, then let
claude-review.yml trigger on the PR via pull_request event. Claude
posts findings as native PR review comments (no JSON extraction).

- claude-review.yml: trigger on pull_request with staging-promotion
  label, uses claude-code-action in native PR review mode, Sonnet
  tags comments with [SEVERITY:CONFIDENCE] for downstream parsing
- staging-ci.yml: new create-promotion-pr job runs in parallel with
  tests/e2e, gate waits for Claude review check on PR, parses
  review comments to create issues and evaluate blocking findings
- create-labels.sh: add staging-promotion label

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-08 15:35:55 -07:00
Henry ParkandClaude Opus 4.6 1ecc41e0ce fix(ci): lean Claude review — 5 turns, diff-only, no prompt embedding [skip-regression-check]
- Reduce --max-turns from 30 to 5 (enough for diff review)
- Remove full source file reading instruction (diff-only review)
- Don't embed diff in prompt — let Claude run git diff itself
- Extract results from execution_file output instead of /tmp/ file
- Reduces cost from ~$1.50 to ~$0.10-0.30 per review

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-08 15:20:45 -07:00
Henry ParkandClaude Opus 4.6 454db90f84 fix(ci): add id-token:write to staging-ci parent workflow [skip-regression-check]
Called workflows inherit permissions from the parent. claude-review.yml
needs id-token:write for OIDC, so the parent staging-ci.yml must also
declare it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-08 15:04:20 -07:00
Henry ParkandClaude Opus 4.6 7e5a50b2de fix(ci): add id-token:write permission for claude-code-action OIDC [skip-regression-check]
The anthropics/claude-code-action@v1 requires OIDC token access for
authentication. Without id-token:write permission, the action fails
with "Could not fetch an OIDC token".

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-08 15:03:06 -07:00
Henry ParkandClaude Opus 4.6 926f868a3e fix(ci): address PR review feedback on staging-ci [skip-regression-check]
- Remove broken skip-claude-gate label check (label always exists after
  bootstrap); keep only workflow_dispatch input override
- Require claude-review.result == 'success' in gate condition so a
  crashed review blocks promotion instead of silently passing
- Load review prompt into GITHUB_ENV instead of $(cat) in YAML with:
  block (YAML doesn't do shell substitution)
- version-check roll-up: check != 'success' instead of == 'failure'
  to catch cancelled/skipped states
- Force run: set diff_range to valid empty range when no new commits
- Align low-confidence label spacing in create-labels.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-08 14:56:33 -07:00
Henry ParkandClaude Opus 4.6 6556d7ebcd ci: staging branch with batched CI and Claude Code review [skip-regression-check]
Redesign CI to speed up PR feedback by deferring heavy tests to a
30-minute batch on the staging branch. PRs only run fmt + clippy +
regression-test-check. The batch CI runs tests, E2E, and a Claude
Code review with confidence-scored findings that gate promotion to
main.

- test.yml: replace PR/push triggers with workflow_call + dispatch
- e2e.yml: replace PR paths trigger with workflow_call
- coverage.yml: add workflow_call + dispatch triggers
- code_style.yml: absorb version-check job from test.yml
- staging-ci.yml: new orchestrator (30-min cron, change detection,
  tests + e2e + claude-review → gate → tag + promote-to-main)
- claude-review.yml: new reusable review workflow with 0-100
  confidence scoring (CRITICAL ≥80 blocks, ≥50 creates issues,
  <50 CRITICAL gets low-confidence label)
- create-labels.sh: add staging-ci-review, skip-claude-gate,
  low-confidence labels

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-07 13:23:02 -08:00
56 changed files with 1345 additions and 4184 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)",
"Bash(cargo fmt:*)",
"Bash(grep:*)",
"Bash(env:*)",
"Skill(ship)"
]
}
}
+4
View File
@@ -64,6 +64,10 @@ create "scope: dependencies" "90A4AE" "Dependency updates"
echo "==> Creating workflow labels..."
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
create "staging-ci-review" "D93F0B" "Auto-created by staging CI Claude Code review"
create "skip-claude-gate" "FBCA04" "Override: bypass Claude CRITICAL gate on staging CI"
create "low-confidence" "C5DEF5" "Claude review finding with <50 confidence"
create "staging-promotion" "0E8A16" "Auto-created staging→main promotion PR"
echo "==> Creating contributor labels..."
create "contributor: new" "FFF9C4" "First-time contributor"
+49
View File
@@ -0,0 +1,49 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
jobs:
review:
name: Claude Code Review
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run Claude Code review
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
Review this PR for security vulnerabilities, bugs, and code quality issues.
Prefix EVERY review comment with a severity and confidence tag:
[SEVERITY:CONFIDENCE] where SEVERITY is CRITICAL/HIGH/MEDIUM/LOW
and CONFIDENCE is 0-100.
Example: [CRITICAL:92] This .unwrap() can panic in production when the config file is missing.
Severity guide:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
Confidence guide:
- 90-100: certain this is a real issue
- 70-89: very likely but needs human verification
- 50-69: possible issue, not fully sure of context
- 0-49: speculative, might be false positive
Only report real issues you're confident about. Be concise. No nitpicks.
claude_args: "--max-turns 5"
+17 -27
View File
@@ -44,42 +44,32 @@ 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"
version-check:
name: Version Bump Check
runs-on: ubuntu-latest
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
- 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
code-style:
name: Code Style (fmt + clippy)
runs-on: ubuntu-latest
if: always()
needs: [format, clippy, clippy-windows]
needs: [format, clippy, version-check]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
if [[ "${{ needs.version-check.result }}" != "success" ]]; then
echo "Version bump check did not succeed (status: ${{ needs.version-check.result }})"
exit 1
fi
+3 -1
View File
@@ -1,7 +1,9 @@
name: Code Coverage
on:
push:
branches: [main]
branches: [main] # Runs when staging merges to main
workflow_call: # Optional: staging-ci can invoke
workflow_dispatch: # Manual trigger
permissions:
id-token: write
+1 -4
View File
@@ -2,11 +2,8 @@ name: E2E Tests
on:
schedule:
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
workflow_call: # Called by staging-ci.yml
workflow_dispatch:
pull_request:
paths:
- "src/channels/web/**"
- "tests/e2e/**"
jobs:
# ── Step 1: compile once ──────────────────────────────────────────────────
+414
View File
@@ -0,0 +1,414 @@
name: Staging CI (Batched)
on:
schedule:
- cron: "*/30 * * * *" # Every 30 minutes
workflow_dispatch:
inputs:
force:
description: "Force run even if no new commits"
type: boolean
default: false
skip_claude_gate:
description: "Skip Claude review gate (bypass blocking findings)"
type: boolean
default: false
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: staging-ci
cancel-in-progress: false # Let running suites finish
jobs:
# ── Check for new commits ──────────────────────────────────────
check-changes:
name: Check for new commits
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
current_head: ${{ steps.check.outputs.current_head }}
diff_range: ${{ steps.check.outputs.diff_range }}
steps:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 0
- name: Check for changes since last tested
id: check
run: |
CURRENT_HEAD=$(git rev-parse HEAD)
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
if git rev-parse staging-tested >/dev/null 2>&1; then
LAST_TESTED=$(git rev-parse staging-tested)
else
LAST_TESTED=""
fi
DIFF_RANGE=""
if [ -n "$LAST_TESTED" ] && [ "$LAST_TESTED" = "$CURRENT_HEAD" ]; then
echo "No new commits since last tested (${CURRENT_HEAD})"
HAS_CHANGES=false
else
HAS_CHANGES=true
if [ -n "$LAST_TESTED" ]; then
COMMIT_COUNT=$(git rev-list --count "${LAST_TESTED}..HEAD")
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
else
git fetch origin main
MERGE_BASE=$(git merge-base origin/main HEAD)
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
fi
fi
# Force override from workflow_dispatch
if [ "${{ inputs.force }}" = "true" ]; then
echo "Force run requested"
HAS_CHANGES=true
if [ -z "$DIFF_RANGE" ]; then
DIFF_RANGE="${CURRENT_HEAD}..${CURRENT_HEAD}"
fi
fi
echo "has_changes=${HAS_CHANGES}" >> "$GITHUB_OUTPUT"
echo "diff_range=${DIFF_RANGE}" >> "$GITHUB_OUTPUT"
# ── Run full test suite ──────────────────────────────────────────
tests:
name: Test Suite
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/test.yml
# ── Run E2E browser tests ────────────────────────────────────────
e2e:
name: E2E Browser Tests
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/e2e.yml
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
create-promotion-pr:
name: Create Promotion PR
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.create-pr.outputs.pr_number }}
steps:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 0
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Check if staging is ahead of main
id: ahead-check
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
git fetch origin main
AHEAD=$(git rev-list --count origin/main..origin/staging)
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of main. Nothing to promote."
else
echo "Staging is ${AHEAD} commits ahead of main."
fi
- name: Close stale promotion PRs
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
# Close any existing staging->main PRs to avoid duplicates
EXISTING=$(gh pr list --base main --head staging --state open --json number -q '.[].number')
for PR in $EXISTING; do
echo "Closing stale promotion PR #${PR}"
gh pr close "$PR" --comment "Superseded by new staging-ci batch run"
done
- name: Create promotion PR
id: create-pr
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
RANGE="${{ needs.check-changes.outputs.diff_range }}"
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC")
PR_URL=$(gh pr create \
--base main \
--head staging \
--title "chore: promote staging to main (${TIMESTAMP})" \
--body "## Auto-promotion from staging CI
**Batch range:** \`${RANGE}\`
**Triggered by:** Staging CI batch at ${TIMESTAMP}
Waiting for gates:
- Tests: pending
- E2E: pending
- Claude Code review: pending (will post comments on this PR)
---
*Auto-created by staging-ci workflow*" \
--label "staging-promotion")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT"
echo "Created promotion PR #${PR_NUM}"
# ── Gate: wait for all checks, process findings, merge or block ──
gate:
name: Staging Gate
needs: [check-changes, tests, e2e, create-promotion-pr]
if: >
always() &&
needs.check-changes.outputs.has_changes == 'true' &&
needs.tests.result == 'success' &&
needs.e2e.result == 'success' &&
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
outputs:
gate_passed: ${{ steps.evaluate.outputs.passed }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Wait for Claude review on PR
id: wait-review
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
if [ -z "$PR_NUMBER" ]; then
echo "No PR number — skipping Claude review wait"
echo "review_done=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Waiting for Claude Code Review check on PR #${PR_NUMBER}..."
TIMEOUT=600 # 10 minutes
ELAPSED=0
INTERVAL=15
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
# Check if the claude-review check has completed
STATUS=$(gh pr checks "$PR_NUMBER" --json name,state \
--jq '.[] | select(.name == "Claude Code Review") | .state' 2>/dev/null || echo "PENDING")
if [ "$STATUS" = "SUCCESS" ] || [ "$STATUS" = "FAILURE" ]; then
echo "Claude review completed with status: ${STATUS}"
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo "review_done=true" >> "$GITHUB_OUTPUT"
break
fi
echo "Claude review status: ${STATUS} (${ELAPSED}s elapsed)"
sleep "$INTERVAL"
ELAPSED=$((ELAPSED + INTERVAL))
done
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::warning::Claude review timed out after ${TIMEOUT}s"
echo "review_status=TIMEOUT" >> "$GITHUB_OUTPUT"
echo "review_done=false" >> "$GITHUB_OUTPUT"
fi
- name: Process Claude review comments and create issues
id: process-findings
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
REPO: ${{ github.repository }}
run: |
HAS_BLOCKING=false
ISSUES_CREATED=0
if [ -z "$PR_NUMBER" ]; then
echo "No PR — skipping finding processing"
echo "has_blocking=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Get all review comments from Claude on this PR
COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" \
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, path: .path, line: .line, url: .html_url}]' 2>/dev/null || echo "[]")
COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
echo "Found ${COMMENT_COUNT} Claude review comment(s)"
# Also check PR review body comments
REVIEW_COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
--jq '[.[] | select(.user.login == "claude[bot]" or .user.type == "Bot") | {body: .body, url: .html_url}]' 2>/dev/null || echo "[]")
# Combine all comments
ALL_COMMENTS=$(echo "$COMMENTS $REVIEW_COMMENTS" | jq -s 'add // []')
# Parse [SEVERITY:CONFIDENCE] tags from each comment
echo "$ALL_COMMENTS" | jq -c '.[]' | while read -r comment; do
BODY=$(echo "$comment" | jq -r '.body // ""')
URL=$(echo "$comment" | jq -r '.url // ""')
FILE=$(echo "$comment" | jq -r '.path // "unknown"')
LINE=$(echo "$comment" | jq -r '.line // 0')
# Extract [SEVERITY:CONFIDENCE] tag
TAG=$(echo "$BODY" | grep -oE '\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]' | head -1 || true)
if [ -z "$TAG" ]; then
continue
fi
SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
# Strip tag from body for issue description
DESC=$(echo "$BODY" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -5)
echo "Found: [${SEVERITY}:${CONFIDENCE}] in ${FILE}:${LINE}"
# Determine if this should create an issue (confidence matrix)
CREATE_ISSUE=false
case "$SEVERITY" in
CRITICAL) CREATE_ISSUE=true ;; # Always
HIGH) [ "$CONFIDENCE" -ge 50 ] && CREATE_ISSUE=true ;;
MEDIUM) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
LOW) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
esac
# Check if blocking (CRITICAL ≥80)
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -ge 80 ]; then
HAS_BLOCKING=true
fi
if [ "$CREATE_ISSUE" = "true" ]; then
# Determine labels
case "$SEVERITY" in
CRITICAL) LABELS="bug,risk: high,staging-ci-review" ;;
HIGH) LABELS="bug,risk: medium,staging-ci-review" ;;
MEDIUM) LABELS="risk: medium,staging-ci-review" ;;
LOW) LABELS="risk: low,staging-ci-review" ;;
esac
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -lt 50 ]; then
LABELS="${LABELS},low-confidence"
fi
TITLE=$(echo "$DESC" | head -1 | cut -c1-80)
{
echo "## ${SEVERITY} Issue Found by Staging CI Review"
echo ""
echo "**Severity:** ${SEVERITY}"
echo "**Confidence:** ${CONFIDENCE}/100"
echo "**File:** \`${FILE}:${LINE}\`"
echo "**PR comment:** ${URL}"
echo ""
echo "### Description"
echo "$DESC"
echo ""
echo "---"
echo "*Auto-created by staging-ci Claude Code review*"
} > /tmp/issue-body.md
gh issue create \
--title "[${SEVERITY}] ${TITLE}" \
--body-file /tmp/issue-body.md \
--label "${LABELS}" || echo "::warning::Failed to create issue for ${SEVERITY} finding"
ISSUES_CREATED=$((ISSUES_CREATED + 1))
fi
done
echo "Created ${ISSUES_CREATED} issues"
echo "has_blocking=${HAS_BLOCKING}" >> "$GITHUB_OUTPUT"
- name: Evaluate gate
id: evaluate
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
HAS_BLOCKING="${{ steps.process-findings.outputs.has_blocking }}"
SKIP_INPUT="${{ inputs.skip_claude_gate }}"
if [ "$HAS_BLOCKING" = "true" ]; then
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
if [ "$SKIP_INPUT" = "true" ]; then
echo "::warning::Gate overridden by skip_claude_gate workflow input"
echo "passed=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Blocking promotion due to CRITICAL findings (≥80 confidence)"
echo "::error::PR #${PR_NUMBER} left open with review comments"
echo "passed=false" >> "$GITHUB_OUTPUT"
exit 1
fi
else
echo "No blocking findings. Gate passed."
echo "passed=true" >> "$GITHUB_OUTPUT"
fi
# Merge the promotion PR
if [ -n "$PR_NUMBER" ]; then
echo "Merging promotion PR #${PR_NUMBER}"
gh pr merge "$PR_NUMBER" --merge --auto || echo "::warning::Auto-merge failed for PR #${PR_NUMBER}"
fi
# ── Update tested tag on success ─────────────────────────────────
update-tag:
name: Update staging-tested tag
needs: [check-changes, gate]
if: >
always() &&
needs.check-changes.outputs.has_changes == 'true' &&
needs.gate.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: staging
fetch-depth: 1
- name: Update staging-tested tag
run: |
git tag -f staging-tested "${{ needs.check-changes.outputs.current_head }}"
git push origin staging-tested --force
echo "Updated staging-tested tag to ${{ needs.check-changes.outputs.current_head }}"
# ── Report ───────────────────────────────────────────────────────
report:
name: Staging CI Summary
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
if: always() && needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
steps:
- name: Summary
run: |
echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY"
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
if [ -n "$PR_NUM" ]; then
echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY"
fi
+4 -51
View File
@@ -1,9 +1,7 @@
name: Run Tests
on:
pull_request:
push:
branches:
- main
workflow_call: # Called by staging-ci.yml
workflow_dispatch: # Manual escape hatch
jobs:
tests:
@@ -51,32 +49,6 @@ 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
@@ -107,34 +79,15 @@ 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, windows-build, version-check]
needs: [tests, telegram-tests, wasm-wit-compat, docker-build]
steps:
- run: |
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
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-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
-1
View File
@@ -28,7 +28,6 @@ COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo build --release --bin ironclaw
-253
View File
@@ -1,253 +0,0 @@
[
{
"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
}
}
]
-32
View File
@@ -17,16 +17,6 @@ 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.
@@ -650,28 +640,6 @@ 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
+2 -29
View File
@@ -16,7 +16,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::llm::{ChatMessage, ImageAttachment, ToolCall};
use crate::llm::{ChatMessage, ToolCall};
/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -250,22 +250,6 @@ 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() {
@@ -336,14 +320,7 @@ impl Thread {
pub fn messages(&self) -> Vec<ChatMessage> {
let mut messages = Vec::new();
for turn in &self.turns {
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(),
));
}
messages.push(ChatMessage::user(&turn.user_input));
if let Some(ref response) = turn.response {
messages.push(ChatMessage::assistant(response));
}
@@ -430,9 +407,6 @@ 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 {
@@ -447,7 +421,6 @@ impl Turn {
started_at: Utc::now(),
completed_at: None,
error: None,
images: Vec::new(),
}
}
+1 -5
View File
@@ -264,11 +264,7 @@ impl Agent {
.threads
.get_mut(&thread_id)
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
if message.images.is_empty() {
thread.start_turn(content);
} else {
thread.start_turn_with_images(content, message.images.clone());
}
thread.start_turn(content);
thread.messages()
};
+2 -4
View File
@@ -1414,11 +1414,9 @@ 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(800),
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
elapsed < Duration::from_millis(500),
"Parallel execution took {:?}, expected < 500ms",
elapsed
);
}
+15 -40
View File
@@ -368,6 +368,21 @@ 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());
@@ -376,46 +391,6 @@ 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
-12
View File
@@ -9,7 +9,6 @@ use futures::Stream;
use uuid::Uuid;
use crate::error::ChannelError;
use crate::llm::ImageAttachment;
/// A message received from an external channel.
#[derive(Debug, Clone)]
@@ -30,8 +29,6 @@ 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 {
@@ -50,7 +47,6 @@ impl IncomingMessage {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
images: Vec::new(),
}
}
@@ -71,12 +67,6 @@ 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.
@@ -173,8 +163,6 @@ pub enum StatusUpdate {
success: bool,
message: String,
},
/// An image was generated or edited by a tool.
ImageGenerated { data_url: String, path: String },
}
impl StatusUpdate {
-3
View File
@@ -585,9 +585,6 @@ impl Channel for ReplChannel {
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
}
}
StatusUpdate::ImageGenerated { path, .. } => {
eprintln!(" \x1b[36m[image]\x1b[0m {path}");
}
}
Ok(())
}
+1 -10
View File
@@ -153,16 +153,7 @@ 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.
//
// 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(),
) {
if let Err(e) = wasmtime_config.cache_config_load_default() {
tracing::warn!("Failed to enable wasmtime compilation cache: {}", e);
}
-5
View File
@@ -2591,11 +2591,6 @@ 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,
},
}
}
-13
View File
@@ -369,19 +369,6 @@ 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);
-1
View File
@@ -247,7 +247,6 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
tool_call_id: None,
name: m.name.clone(),
tool_calls: None,
images: Vec::new(),
}),
}
})
+12 -31
View File
@@ -43,7 +43,6 @@ 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;
@@ -627,17 +626,6 @@ 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={:?}",
@@ -963,25 +951,18 @@ async fn chat_history_handler(
tool_calls: t
.tool_calls
.iter()
.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(),
}
.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(),
})
.collect(),
})
-5
View File
@@ -55,10 +55,6 @@ 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);
}
@@ -147,7 +143,6 @@ 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))
});
+6 -148
View File
@@ -41,9 +41,6 @@ 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 = {};
@@ -116,78 +113,6 @@ 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) {
@@ -390,17 +315,6 @@ 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;
@@ -516,28 +430,19 @@ function sendMessage() {
return;
}
const content = input.value.trim();
if (!content && stagedImages.length === 0) return;
if (!content) 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, images },
body: { content, thread_id: currentThreadId || undefined },
}).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message);
});
// Clear staged images after sending
stagedImages = [];
renderImagePreviews();
}
function enableChatInput() {
@@ -953,30 +858,6 @@ 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');
@@ -1342,33 +1223,10 @@ function createToolCallsSummaryElement(toolCalls) {
item.appendChild(nameSpan);
if (tc.result_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);
}
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');
-3
View File
@@ -129,10 +129,7 @@
<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>
-98
View File
@@ -1093,37 +1093,6 @@ 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);
@@ -1356,73 +1325,6 @@ 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;
+2 -34
View File
@@ -5,18 +5,10 @@ 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)]
@@ -233,17 +225,6 @@ 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 ---
@@ -625,8 +606,6 @@ 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")]
@@ -694,7 +673,6 @@ 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 {
@@ -813,14 +791,9 @@ 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,
images,
} => {
WsClientMessage::Message { content, thread_id } => {
assert_eq!(content, "hello");
assert_eq!(thread_id.as_deref(), Some("t1"));
assert!(images.is_empty());
}
_ => panic!("Expected Message variant"),
}
@@ -831,14 +804,9 @@ 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,
images,
} => {
WsClientMessage::Message { content, thread_id } => {
assert_eq!(content, "hi");
assert!(thread_id.is_none());
assert!(images.is_empty());
}
_ => panic!("Expected Message variant"),
}
+1 -18
View File
@@ -22,7 +22,6 @@ 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 {
@@ -157,26 +156,12 @@ async fn handle_client_message(
direct_tx: &mpsc::Sender<WsServerMessage>,
) {
match msg {
WsClientMessage::Message {
content,
thread_id,
images,
} => {
WsClientMessage::Message { content, thread_id } => {
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() {
@@ -364,7 +349,6 @@ mod tests {
WsClientMessage::Message {
content: "hello agent".to_string(),
thread_id: Some("t1".to_string()),
images: vec![],
},
&state,
"user1",
@@ -389,7 +373,6 @@ mod tests {
WsClientMessage::Message {
content: "hello".to_string(),
thread_id: None,
images: vec![],
},
&state,
"user1",
+2 -6
View File
@@ -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\n ironclaw onboard --provider-only # Change LLM provider and model"
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels"
)]
Onboard {
/// Skip authentication (use existing session)
@@ -94,12 +94,8 @@ pub enum Command {
skip_auth: bool,
/// Reconfigure channels only
#[arg(long, conflicts_with = "provider_only")]
#[arg(long)]
channels_only: bool,
/// Reconfigure LLM provider and model only
#[arg(long, conflicts_with = "channels_only")]
provider_only: bool,
},
/// Manage configuration settings
+294 -409
View File
@@ -5,49 +5,141 @@ 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;
/// Resolved configuration for a registry-based provider.
/// Which LLM backend to use.
///
/// 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.
/// 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.
#[derive(Debug, Clone)]
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 struct OpenAiDirectConfig {
pub api_key: SecretString,
pub model: String,
/// Extra HTTP headers injected into every request.
/// 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 api_key: Option<SecretString>,
pub model: String,
/// Extra HTTP headers injected into every LLM request.
/// Parsed from `LLM_EXTRA_HEADERS` env var (format: `Key:Value,Key2:Value2`).
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.
///
/// 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`.
/// NEAR AI remains the default backend. Users can switch to other providers
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
#[derive(Debug, Clone)]
pub struct LlmConfig {
/// 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).
/// Which backend to use (default: NearAi)
pub backend: LlmBackend,
/// NEAR AI config (always populated for NEAR AI embeddings, etc.)
pub nearai: NearAiConfig,
/// Resolved provider config for registry-based providers.
/// `None` when backend is "nearai".
pub provider: Option<RegistryProviderConfig>,
/// 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>,
}
/// NEAR AI configuration.
@@ -56,47 +148,67 @@ 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,
/// API key for NEAR AI Cloud.
/// 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.
pub api_key: Option<SecretString>,
/// Optional fallback model for failover.
/// 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.
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 failures before circuit breaker opens. None = disabled.
/// 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.
pub circuit_breaker_threshold: Option<u32>,
/// Seconds the circuit stays open before probing (default: 30).
/// How long (seconds) the circuit stays open before allowing a probe (default: 30).
pub circuit_breaker_recovery_secs: u64,
/// Enable in-memory response caching. Default: false.
/// Enable in-memory response caching for `complete()` calls.
/// Saves tokens on repeated prompts within a session. Default: false.
pub response_cache_enabled: bool,
/// TTL in seconds for cached responses (default: 3600).
/// TTL in seconds for cached responses (default: 3600 = 1 hour).
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 failover (default: 300).
/// Cooldown duration in seconds for the failover provider (default: 300).
/// When a provider accumulates enough consecutive failures it is skipped
/// for this many seconds.
pub failover_cooldown_secs: u64,
/// Consecutive failures before failover cooldown (default: 3).
/// Number of consecutive retryable failures before a provider enters
/// cooldown (default: 3).
pub failover_cooldown_threshold: u32,
/// Enable cascade mode for smart routing. Default: true.
/// 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.
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: "nearai".to_string(),
session: SessionConfig {
auth_base_url: "http://localhost:0".to_string(),
session_path: PathBuf::from("/tmp/ironclaw-test-session.json"),
},
backend: LlmBackend::NearAi,
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,
@@ -109,11 +221,15 @@ impl LlmConfig {
failover_cooldown_threshold: 3,
smart_routing_cascade: false,
},
provider: None,
openai: None,
anthropic: None,
ollama: None,
openai_compatible: None,
tinfoil: 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,
@@ -125,40 +241,31 @@ impl LlmConfig {
}
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let registry = ProviderRegistry::load();
// Determine backend: env var > settings > default ("nearai")
let backend = if let Some(b) = optional_env("LLM_BACKEND")? {
b
// 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,
})?
} else if let Some(ref b) = settings.llm_backend {
b.clone()
match b.parse() {
Ok(backend) => backend,
Err(e) => {
tracing::warn!(
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
b,
e
);
LlmBackend::NearAi
}
}
} else {
"nearai".to_string()
LlmBackend::NearAi
};
// 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)
// 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);
let nearai = NearAiConfig {
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
@@ -169,6 +276,11 @@ 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)?,
@@ -188,155 +300,107 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
// Resolve registry provider config (for non-NearAI backends)
let provider = if is_nearai {
None
// 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 {
Some(Self::resolve_registry_provider(
&backend_lower,
&registry,
settings,
)?)
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
};
Ok(Self {
backend: if is_nearai {
"nearai".to_string()
} else if let Some(ref p) = provider {
p.provider_id.clone()
} else {
backend_lower
},
session,
backend,
nearai,
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,
openai,
anthropic,
ollama,
openai_compatible,
tinfoil,
})
}
}
/// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs.
///
/// Format: `Key1:Value1,Key2:Value2` (colon-separated, not `=`, because
/// header values often contain `=`).
/// 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).
fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError> {
if val.trim().is_empty() {
return Ok(Vec::new());
@@ -400,9 +464,11 @@ mod tests {
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("provider config should be present");
let compat = cfg
.openai_compatible
.expect("openai-compatible config should be present");
assert_eq!(provider.model, "openai/gpt-5.1-codex");
assert_eq!(compat.model, "openai/gpt-5.1-codex");
}
#[test]
@@ -422,9 +488,11 @@ mod tests {
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("provider config should be present");
let compat = cfg
.openai_compatible
.expect("openai-compatible config should be present");
assert_eq!(provider.model, "openai/gpt-5-codex");
assert_eq!(compat.model, "openai/gpt-5-codex");
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -470,6 +538,7 @@ 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,
@@ -518,9 +587,9 @@ mod tests {
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("provider config should be present");
let ollama = cfg.ollama.expect("ollama config should be present");
assert_eq!(provider.model, "llama3.2");
assert_eq!(ollama.model, "llama3.2");
}
#[test]
@@ -539,9 +608,9 @@ mod tests {
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("provider config should be present");
let ollama = cfg.ollama.expect("ollama config should be present");
assert_eq!(provider.model, "mistral:latest");
assert_eq!(ollama.model, "mistral:latest");
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -562,197 +631,13 @@ mod tests {
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("provider config should be present");
let compat = cfg
.openai_compatible
.expect("openai-compatible config should be present");
assert_eq!(
provider.model, "llama3.2",
compat.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");
}
}
}
+10 -25
View File
@@ -36,7 +36,10 @@ 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::{LlmConfig, NearAiConfig, RegistryProviderConfig};
pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
};
pub use self::routines::RoutineConfig;
pub use self::safety::SafetyConfig;
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
@@ -44,7 +47,6 @@ 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).
///
@@ -284,29 +286,12 @@ pub async fn inject_llm_keys_from_secrets(
secrets: &dyn crate::secrets::SecretsStore,
user_id: &str,
) {
// 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 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"),
];
let mut injected = HashMap::new();
-2
View File
@@ -292,8 +292,6 @@ 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(())
}
}
+20 -30
View File
@@ -561,10 +561,7 @@ impl WorkspaceStore for LibSqlBackend {
.join(",")
);
// 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
let mut rows = conn
.query(
r#"
SELECT c.id, c.document_id, d.path, c.content
@@ -576,34 +573,27 @@ 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),
})?
{
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.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
} else {
Vec::new()
};
+5 -137
View File
@@ -2,9 +2,6 @@
//!
//! 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.
///
@@ -15,7 +12,7 @@
/// - `BYTEA` -> `BLOB`
/// - `NUMERIC` -> `TEXT` (preserve precision for rust_decimal)
/// - `TEXT[]` -> `TEXT` (JSON array)
/// - `VECTOR` -> `BLOB` (raw little-endian F32 bytes, any dimension)
/// - `VECTOR(1536)` -> `F32_BLOB(1536)` (libsql native)
/// - `TSVECTOR` -> FTS5 virtual table
/// - `BIGSERIAL` -> `INTEGER PRIMARY KEY AUTOINCREMENT`
/// - PL/pgSQL functions -> SQLite triggers
@@ -224,16 +221,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 BLOB,
embedding F32_BLOB(1536),
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);
-- 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.
-- Vector index for semantic search (libSQL native)
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding
ON memory_chunks (libsql_vector_idx(embedding));
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
@@ -569,132 +566,3 @@ 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(())
}
-139
View File
@@ -1,139 +0,0 @@
//! 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()));
}
}
+178 -147
View File
@@ -10,33 +10,28 @@
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, ImageAttachment, LlmProvider,
ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition,
ToolResult,
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, 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;
@@ -48,29 +43,26 @@ use std::sync::Arc;
use rig::client::CompletionClient;
use secrecy::ExposeSecret;
use crate::config::{LlmConfig, NearAiConfig, RegistryProviderConfig};
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
use crate::error::LlmError;
/// Create an LLM provider based on configuration.
///
/// - NearAI backend: Uses session manager for authentication
/// - Registry providers: Looked up by protocol and constructed generically
/// - `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
pub fn create_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
return create_llm_provider_with_config(&config.nearai, session);
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),
}
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.
@@ -95,151 +87,184 @@ pub fn create_llm_provider_with_config(
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
}
/// 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),
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())
}
.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_openai_compat_from_registry(
config: &RegistryProviderConfig,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
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(),
})?;
use rig::providers::openai;
let mut extra_headers = reqwest::header::HeaderMap::new();
for (key, value) in &config.extra_headers {
for (key, value) in &compat.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 extra header: invalid name");
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header name");
continue;
}
};
let val = match reqwest::header::HeaderValue::from_str(value) {
Ok(v) => v,
Err(e) => {
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value");
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header value");
continue;
}
};
extra_headers.insert(name, val);
}
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)
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)
.build()
.map_err(|e| LlmError::RequestFailed {
provider: config.provider_id.clone(),
reason: format!("Failed to create Ollama client: {e}"),
})?;
let model = client.completion_model(&config.model);
provider: "openai_compatible".to_string(),
reason: format!("Failed to create OpenAI-compatible client: {}", e),
})?
.completions_api();
let model = client.completion_model(&compat.model);
tracing::info!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
"Using Ollama provider"
"Using OpenAI-compatible endpoint (chat completions, base_url: {}, model: {})",
compat.base_url,
compat.model
);
Ok(Arc::new(RigAdapter::new(model, &config.model)))
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
}
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
@@ -254,9 +279,9 @@ pub fn create_cheap_llm_provider(
return Ok(None);
};
if config.backend != "nearai" {
if config.backend != LlmBackend::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
);
@@ -431,13 +456,16 @@ pub fn build_provider_chain(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NearAiConfig;
use crate::config::{LlmBackend, NearAiConfig};
use std::path::PathBuf;
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,
@@ -454,10 +482,13 @@ mod tests {
fn test_llm_config() -> LlmConfig {
LlmConfig {
backend: "nearai".to_string(),
session: SessionConfig::default(),
backend: LlmBackend::NearAi,
nearai: test_nearai_config(),
provider: None,
openai: None,
anthropic: None,
ollama: None,
openai_compatible: None,
tinfoil: None,
}
}
@@ -488,7 +519,7 @@ mod tests {
#[test]
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
let mut config = test_llm_config();
config.backend = "openai".to_string();
config.backend = LlmBackend::OpenAi;
config.nearai.cheap_model = Some("cheap-test-model".to_string());
let session = Arc::new(SessionManager::new(SessionConfig::default()));
+38 -197
View File
@@ -138,45 +138,13 @@ 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 {
return Ok(api_key.expose_secret().to_string());
}
// 2. Existing session token (OAuth was already completed)
if self.session.has_token().await {
Ok(api_key.expose_secret().to_string())
} else {
let token = self.session.get_token().await?;
return Ok(token.expose_secret().to_string());
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.
@@ -671,7 +639,7 @@ struct ChatCompletionRequest {
struct ChatCompletionMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<serde_json::Value>,
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -839,15 +807,10 @@ 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(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());
}
if let Some(ref text) = msg.content
&& !text.is_empty()
{
parts.push(text.clone());
}
for tc in calls {
parts.push(format!(
@@ -857,7 +820,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
}
ChatCompletionMessage {
role: "assistant".to_string(),
content: Some(serde_json::json!(parts.join("\n"))),
content: Some(parts.join("\n")),
tool_call_id: None,
name: None,
@@ -866,16 +829,10 @@ 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 = match &msg.content {
Some(serde_json::Value::String(s)) => s.as_str(),
_ => "",
};
let result = msg.content.as_deref().unwrap_or("");
ChatCompletionMessage {
role: "user".to_string(),
content: Some(serde_json::json!(format!(
"[Tool `{}` returned: {}]",
tool_name, result
))),
content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
tool_call_id: None,
name: None,
@@ -913,23 +870,8 @@ 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(serde_json::json!(msg.content))
Some(msg.content)
};
Self {
@@ -1041,6 +983,8 @@ 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,
@@ -1094,7 +1038,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(serde_json::json!("Hello")));
assert_eq!(chat_msg.content, Some("Hello".to_string()));
}
#[test]
@@ -1168,14 +1112,14 @@ mod tests {
let messages = vec![
ChatCompletionMessage {
role: "system".to_string(),
content: Some(serde_json::json!("You are helpful.")),
content: Some("You are helpful.".to_string()),
tool_call_id: None,
name: None,
tool_calls: None,
},
ChatCompletionMessage {
role: "user".to_string(),
content: Some(serde_json::json!("Hello")),
content: Some("Hello".to_string()),
tool_call_id: None,
name: None,
tool_calls: None,
@@ -1192,7 +1136,7 @@ mod tests {
let messages = vec![
ChatCompletionMessage {
role: "user".to_string(),
content: Some(serde_json::json!("test")),
content: Some("test".to_string()),
tool_call_id: None,
name: None,
tool_calls: None,
@@ -1213,7 +1157,7 @@ mod tests {
},
ChatCompletionMessage {
role: "tool".to_string(),
content: Some(serde_json::json!("hi")),
content: Some("hi".to_string()),
tool_call_id: Some("call_1".to_string()),
name: Some("echo".to_string()),
tool_calls: None,
@@ -1226,28 +1170,24 @@ mod tests {
// Assistant tool_calls → plain assistant text
assert_eq!(result[1].role, "assistant");
assert!(result[1].tool_calls.is_none());
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");
}
assert!(
result[1]
.content
.as_ref()
.unwrap()
.contains("[Called tool `echo`")
);
// Tool result → user message
assert_eq!(result[2].role, "user");
assert!(result[2].tool_call_id.is_none());
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");
}
assert!(
result[2]
.content
.as_ref()
.unwrap()
.contains("[Tool `echo` returned: hi]")
);
}
#[test]
@@ -1255,7 +1195,7 @@ mod tests {
let messages = vec![
ChatCompletionMessage {
role: "assistant".to_string(),
content: Some(serde_json::json!("Let me check that.")),
content: Some("Let me check that.".to_string()),
tool_call_id: None,
name: None,
tool_calls: Some(vec![ChatCompletionToolCall {
@@ -1269,7 +1209,7 @@ mod tests {
},
ChatCompletionMessage {
role: "tool".to_string(),
content: Some(serde_json::json!("found it")),
content: Some("found it".to_string()),
tool_call_id: Some("call_1".to_string()),
name: Some("search".to_string()),
tool_calls: None,
@@ -1277,16 +1217,9 @@ mod tests {
];
let result = flatten_tool_messages(messages);
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");
}
let text = result[0].content.as_ref().unwrap();
assert!(text.starts_with("Let me check that."));
assert!(text.contains("[Called tool `search`"));
}
#[test]
@@ -1466,96 +1399,4 @@ mod tests {
);
assert!(tool_calls.is_empty());
}
#[tokio::test]
async fn test_resolve_bearer_token_config_api_key() {
// When config.api_key is set, it takes top priority.
let cfg = test_nearai_config("http://localhost:8318");
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
let token = provider
.resolve_bearer_token()
.await
.expect("should resolve");
assert_eq!(token, "test-key");
}
#[tokio::test]
async fn test_resolve_bearer_token_session_token() {
// When config.api_key is None but session has a token, use session token.
let mut cfg = test_nearai_config("http://localhost:8318");
cfg.api_key = None;
let session = test_session();
session
.set_token(secrecy::SecretString::from("session-tok-123".to_string()))
.await;
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
let token = provider
.resolve_bearer_token()
.await
.expect("should resolve");
assert_eq!(token, "session-tok-123");
}
#[tokio::test]
async fn test_resolve_bearer_token_session_beats_env_var() {
// Session token takes priority over NEARAI_API_KEY env var.
// This prevents unexpected auth mode switches mid-run.
let mut cfg = test_nearai_config("http://localhost:8318");
cfg.api_key = None;
let session = test_session();
session
.set_token(secrecy::SecretString::from("oauth-token".to_string()))
.await;
// Set env var that should NOT be used when session token exists
#[allow(unused_unsafe)]
unsafe {
std::env::set_var("NEARAI_API_KEY", "env-api-key-should-not-win");
}
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
let token = provider
.resolve_bearer_token()
.await
.expect("should resolve");
assert_eq!(
token, "oauth-token",
"session token must take priority over env var"
);
#[allow(unused_unsafe)]
unsafe {
std::env::remove_var("NEARAI_API_KEY");
}
}
#[tokio::test]
async fn test_resolve_bearer_token_config_beats_session_and_env() {
// Config API key should win even when session token AND env var are set.
let cfg = test_nearai_config("http://localhost:8318");
let session = test_session();
session
.set_token(secrecy::SecretString::from("session-tok".to_string()))
.await;
#[allow(unused_unsafe)]
unsafe {
std::env::set_var("NEARAI_API_KEY", "env-key");
}
let provider = NearAiChatProvider::new(cfg, session).expect("provider");
let token = provider
.resolve_bearer_token()
.await
.expect("should resolve");
assert_eq!(
token, "test-key",
"config api_key must win over session token and env var"
);
#[allow(unused_unsafe)]
unsafe {
std::env::remove_var("NEARAI_API_KEY");
}
}
}
-29
View File
@@ -16,15 +16,6 @@ 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 {
@@ -40,9 +31,6 @@ 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 {
@@ -54,7 +42,6 @@ impl ChatMessage {
tool_call_id: None,
name: None,
tool_calls: None,
images: Vec::new(),
}
}
@@ -66,19 +53,6 @@ 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,
}
}
@@ -90,7 +64,6 @@ impl ChatMessage {
tool_call_id: None,
name: None,
tool_calls: None,
images: Vec::new(),
}
}
@@ -109,7 +82,6 @@ impl ChatMessage {
} else {
Some(tool_calls)
},
images: Vec::new(),
}
}
@@ -125,7 +97,6 @@ impl ChatMessage {
tool_call_id: Some(tool_call_id.into()),
name: Some(name.into()),
tool_calls: None,
images: Vec::new(),
}
}
}
-725
View File
@@ -1,725 +0,0 @@
//! 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
);
}
}
}
}
+3 -31
View File
@@ -10,8 +10,8 @@ use rig::completion::{
ToolDefinition as RigToolDefinition, Usage as RigUsage,
};
use rig::message::{
DocumentSourceKind, Image, ImageMediaType, Message as RigMessage, ToolChoice as RigToolChoice,
ToolFunction, ToolResult as RigToolResult, ToolResultContent, UserContent,
Message as RigMessage, ToolChoice as RigToolChoice, ToolFunction, ToolResult as RigToolResult,
ToolResultContent, UserContent,
};
use rust_decimal::Decimal;
use serde::Serialize;
@@ -230,33 +230,7 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
}
}
crate::llm::Role::User => {
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));
}
}
history.push(RigMessage::user(&msg.content));
}
crate::llm::Role::Assistant => {
if let Some(ref tool_calls) = msg.tool_calls {
@@ -661,7 +635,6 @@ 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] {
@@ -811,7 +784,6 @@ 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);
-160
View File
@@ -1,160 +0,0 @@
//! 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())
);
}
}
+35 -7
View File
@@ -23,7 +23,7 @@ use ironclaw::{
},
config::Config,
hooks::bootstrap_hooks,
llm::create_session_manager,
llm::{SessionConfig, create_session_manager},
orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
api::OrchestratorState,
@@ -121,21 +121,19 @@ 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, provider_only);
let _ = (skip_auth, channels_only);
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
}
return Ok(());
@@ -174,8 +172,12 @@ async fn async_main() -> anyhow::Result<()> {
Err(e) => return Err(e.into()),
};
// Initialize session manager before channel setup
let session = create_session_manager(config.llm.session.clone()).await;
// 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;
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
let log_broadcaster = Arc::new(LogBroadcaster::new());
@@ -204,6 +206,13 @@ 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;
@@ -729,12 +738,31 @@ async fn run_memory_command(mem_cmd: &ironclaw::cli::MemoryCommand) -> anyhow::R
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let session = create_session_manager(config.llm.session.clone()).await;
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 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))?;
+1 -3
View File
@@ -26,9 +26,7 @@
//! ```
use std::collections::HashMap;
use std::path::Path;
#[cfg(unix)]
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use bollard::Docker;
-3
View File
@@ -20,11 +20,9 @@
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.
@@ -263,7 +261,6 @@ 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(
-1
View File
@@ -309,7 +309,6 @@ 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)
+211 -395
View File
@@ -73,8 +73,6 @@ 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.
@@ -146,16 +144,6 @@ 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;
@@ -790,31 +778,56 @@ impl SetupWizard {
/// Step 3: Inference provider selection.
///
/// Uses the provider registry to dynamically build the selection menu.
/// NearAI is always first (special auth), then all registry providers
/// that have setup hints.
/// Lets the user pick from all supported LLM backends, then runs the
/// provider-specific auth sub-flow (API key entry, NEAR AI login, etc.).
async fn step_inference_provider(&mut self) -> Result<(), SetupError> {
let registry = crate::llm::ProviderRegistry::load();
// 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(&current) {
def.setup
.as_ref()
.map(|s| s.display_name().to_string())
.unwrap_or_else(|| def.id.clone())
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 display = if is_openrouter {
"OpenRouter"
} else {
current.clone()
match current.as_str() {
"nearai" => "NEAR AI",
"anthropic" => "Anthropic (Claude)",
"openai" => "OpenAI",
"ollama" => "Ollama (local)",
"openai_compatible" => "OpenAI-compatible endpoint",
other => other,
}
};
print_info(&format!("Current provider: {}", display));
println!();
let is_known = current == "nearai" || registry.is_known(&current);
let is_known = matches!(
current.as_str(),
"nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
);
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
return self.run_provider_setup(&current, &registry).await;
// 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
)));
}
}
}
if !is_known {
@@ -828,105 +841,25 @@ impl SetupWizard {
print_info("Select your inference provider:");
println!();
// 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 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.)",
];
options.push("NEAR AI - multi-model access via NEAR account".to_string());
provider_ids.push("nearai".to_string());
let choice = select_one("Provider:", options).map_err(SetupError::Io)?;
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, &registry).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?;
}
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())),
}
Ok(())
@@ -991,7 +924,33 @@ impl SetupWizard {
Ok(())
}
/// Shared setup flow for API-key-based providers.
/// 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).
async fn setup_api_key_provider(
&mut self,
backend: &str,
@@ -1059,12 +1018,9 @@ impl SetupWizard {
Ok(())
}
/// 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());
/// 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());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
@@ -1073,17 +1029,10 @@ 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(
&format!("{display_name} base URL"),
"Ollama base URL",
Some(&format!("default: {}", default_url)),
)
.map_err(SetupError::Io)?;
@@ -1091,18 +1040,31 @@ impl SetupWizard {
let url = url_input.unwrap_or_else(|| default_url.to_string());
self.settings.ollama_base_url = Some(url.clone());
print_success(&format!("{display_name} configured ({})", url));
print_success(&format!("Ollama configured ({})", url));
Ok(())
}
/// 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());
/// 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());
if self.settings.selected_model.is_some() {
self.settings.selected_model = None;
}
@@ -1122,9 +1084,9 @@ impl SetupWizard {
};
if url.is_empty() {
return Err(SetupError::Config(format!(
"Base URL is required for {display_name}"
)));
return Err(SetupError::Config(
"Base URL is required for OpenAI-compatible provider".to_string(),
));
}
self.settings.openai_compatible_base_url = Some(url.clone());
@@ -1136,17 +1098,19 @@ impl SetupWizard {
if !key_str.is_empty() {
if let Ok(ctx) = self.init_secrets_context().await {
ctx.save_secret(secret_name, &key)
ctx.save_secret("llm_compatible_api_key", &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 the API key in your environment.");
print_info("Secrets not available. Set LLM_API_KEY in your environment.");
}
}
}
print_success(&format!("{display_name} configured ({})", url));
print_success(&format!("OpenAI-compatible configured ({})", url));
Ok(())
}
@@ -1171,120 +1135,73 @@ impl SetupWizard {
}
let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai");
let registry = crate::llm::ProviderRegistry::load();
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
match backend {
"anthropic" => {
let cached = self
.llm_api_key
.as_ref()
.map(|k| k.expose_secret().to_string());
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
};
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;
if models.is_empty() {
// 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)?;
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()));
}
} 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));
}
} 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()));
_ => {
// 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)?;
}
self.settings.selected_model = Some(model_id.clone());
print_success(&format!("Selected {}", model_id));
}
Ok(())
@@ -1337,15 +1254,13 @@ impl SetupWizard {
.unwrap_or_else(|_| "https://private.near.ai".to_string());
let config = LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::llm::session::default_session_path(),
},
backend: crate::config::LlmBackend::NearAi,
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,
@@ -1358,7 +1273,11 @@ impl SetupWizard {
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
},
provider: None,
openai: None,
anthropic: None,
ollama: None,
openai_compatible: None,
tinfoil: None,
};
match create_llm_provider(&config, session) {
@@ -2082,108 +2001,89 @@ 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 registry = crate::llm::ProviderRegistry::load();
let mut env_vars: Vec<(String, String)> = Vec::new();
let mut env_vars: Vec<(&str, String)> = Vec::new();
if let Some(ref backend) = self.settings.database_backend {
env_vars.push(("DATABASE_BACKEND".to_string(), backend.clone()));
env_vars.push(("DATABASE_BACKEND", backend.clone()));
}
if let Some(ref url) = self.settings.database_url {
env_vars.push(("DATABASE_URL".to_string(), url.clone()));
env_vars.push(("DATABASE_URL", url.clone()));
}
if let Some(ref path) = self.settings.libsql_path {
env_vars.push(("LIBSQL_PATH".to_string(), path.clone()));
env_vars.push(("LIBSQL_PATH", path.clone()));
}
if let Some(ref url) = self.settings.libsql_url {
env_vars.push(("LIBSQL_URL".to_string(), url.clone()));
env_vars.push(("LIBSQL_URL", 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".to_string(), backend.clone()));
env_vars.push(("LLM_BACKEND", backend.clone()));
}
if let Some(ref url) = self.settings.openai_compatible_base_url {
env_vars.push(("LLM_BASE_URL".to_string(), url.clone()));
env_vars.push(("LLM_BASE_URL", url.clone()));
}
if let Some(ref url) = self.settings.ollama_base_url {
env_vars.push(("OLLAMA_BASE_URL".to_string(), url.clone()));
env_vars.push(("OLLAMA_BASE_URL", 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 (looked up from the provider registry).
// picks it up.
if let Some(ref model) = self.settings.selected_model {
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()));
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()));
}
// 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".to_string(), api_key));
env_vars.push(("NEARAI_API_KEY", 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".to_string(), "true".to_string()));
env_vars.push(("ONBOARD_COMPLETED", "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".to_string(), url.clone()));
env_vars.push(("SIGNAL_HTTP_URL", url.clone()));
}
if let Some(ref account) = self.settings.channels.signal_account {
env_vars.push(("SIGNAL_ACCOUNT".to_string(), account.clone()));
env_vars.push(("SIGNAL_ACCOUNT", account.clone()));
}
if let Some(ref allow_from) = self.settings.channels.signal_allow_from {
env_vars.push(("SIGNAL_ALLOW_FROM".to_string(), allow_from.clone()));
env_vars.push(("SIGNAL_ALLOW_FROM", 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".to_string(),
allow_from_groups.clone(),
));
env_vars.push(("SIGNAL_ALLOW_FROM_GROUPS", allow_from_groups.clone()));
}
if let Some(ref dm_policy) = self.settings.channels.signal_dm_policy {
env_vars.push(("SIGNAL_DM_POLICY".to_string(), dm_policy.clone()));
env_vars.push(("SIGNAL_DM_POLICY", dm_policy.clone()));
}
if let Some(ref group_policy) = self.settings.channels.signal_group_policy {
env_vars.push(("SIGNAL_GROUP_POLICY".to_string(), group_policy.clone()));
env_vars.push(("SIGNAL_GROUP_POLICY", 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".to_string(),
group_allow_from.clone(),
));
env_vars.push(("SIGNAL_GROUP_ALLOW_FROM", group_allow_from.clone()));
}
if !env_vars.is_empty() {
let pairs: Vec<(&str, &str)> = env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let pairs: Vec<(&str, &str)> = env_vars.iter().map(|(k, v)| (*k, 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: {}",
@@ -2758,51 +2658,6 @@ 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.
@@ -3093,7 +2948,6 @@ 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);
@@ -3290,42 +3144,4 @@ 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", &registry)
.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"
);
}
}
-236
View File
@@ -1,236 +0,0 @@
//! 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"
);
}
}
-231
View File
@@ -1,231 +0,0 @@
//! 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)
}
}
-203
View File
@@ -1,203 +0,0 @@
//! 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)
}
}
-6
View File
@@ -4,9 +4,6 @@ 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;
@@ -26,9 +23,6 @@ pub use extension_tools::{
};
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
pub use http::HttpTool;
pub use image_analyze::ImageAnalyzeTool;
pub use image_edit::ImageEditTool;
pub use image_gen::ImageGenerateTool;
pub use job::{
CancelJobTool, CreateJobTool, JobEventsTool, JobPromptTool, JobStatusTool, ListJobsTool,
PromptQueue, SchedulerSlot,
+5 -36
View File
@@ -17,11 +17,11 @@ use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
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,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool, PromptQueue, ReadFileTool,
ShellTool, SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool, TimeTool,
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
WriteFileTool,
};
use crate::tools::rate_limiter::RateLimiter;
use crate::tools::tool::{Tool, ToolDomain};
@@ -71,9 +71,6 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
"message",
"web_fetch",
"restart",
"image_generate",
"image_edit",
"image_analyze",
];
/// Registry of available tools.
@@ -305,34 +302,6 @@ 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.
+1 -1
View File
@@ -102,7 +102,7 @@ pub use limits::{
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
WasmResourceLimiter,
};
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime, enable_compilation_cache};
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime};
pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
// Capabilities (V2)
+2 -118
View File
@@ -4,7 +4,7 @@
//! This matches NEAR blockchain patterns for deterministic, isolated execution.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@@ -18,58 +18,6 @@ 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 {
@@ -188,14 +136,7 @@ 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.
//
// 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())
{
if let Err(e) = wasmtime_config.cache_config_load_default() {
tracing::warn!("Failed to enable wasmtime compilation cache: {}", e);
}
@@ -407,63 +348,6 @@ 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
-61
View File
@@ -249,67 +249,6 @@ 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);
-3
View File
@@ -203,7 +203,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
images: vec![],
};
let fired = engine.check_event_triggers(&matching_msg).await;
assert!(
@@ -224,7 +223,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
images: vec![],
};
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
@@ -288,7 +286,6 @@ mod tests {
thread_id: None,
received_at: Utc::now(),
metadata: serde_json::json!({}),
images: vec![],
};
let fired1 = engine.check_event_triggers(&msg).await;
assert!(fired1 >= 1, "First fire should work");
+6 -2
View File
@@ -14,7 +14,7 @@ use ironclaw::{
agent::HeartbeatRunner,
config::Config,
history::Store,
llm::{create_llm_provider, create_session_manager},
llm::{SessionConfig, create_llm_provider, create_session_manager},
safety::SafetyLayer,
workspace::Workspace,
};
@@ -84,7 +84,11 @@ async fn test_heartbeat_end_to_end() {
}
// 5. Create LLM provider
let session = create_session_manager(config.llm.session.clone()).await;
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 llm = create_llm_provider(&config.llm, session).expect("Failed to create LLM provider");
println!("[5/6] LLM provider created (model: {})", llm.model_name());