feat: BYOP provider + tunnel wizard + SVG architecture diagram
Custom Provider (Bring Your Own): - Add custom:URL format to provider factory (any OpenAI-compatible API) - Works with LiteLLM, LocalAI, vLLM, text-generation-webui, LM Studio, etc. - Example: default_provider = 'custom:http://localhost:1234' - 4 new tests for custom provider (URL, localhost, no-key, empty-URL error) Setup Wizard (6 steps, 5-year-old friendly): - Add '🔧 Custom' tier to provider selection with guided BYOP flow - Add Step 4: Tunnel setup (Cloudflare, Tailscale, ngrok, Custom, or skip) - Emoji labels on all provider categories for visual clarity - Renumber wizard to 6 steps (was 5) Architecture Diagram: - New SVG diagram at docs/architecture.svg (dark theme, color-coded) - Shows: Chat Apps → Security → Agent Loop → AI Providers - Shows: Tunnel layer, Sandbox, Context, Heartbeat/Cron - Shows: Setup Wizard 6-step flow at bottom - Replace ASCII art in README with SVG embed 657 tests passing, 0 clippy warnings, cargo fmt clean
This commit is contained in:
parent
390cbc0a6c
commit
cc6fc6ce8d
4 changed files with 516 additions and 60 deletions
53
README.md
53
README.md
|
|
@ -15,7 +15,7 @@
|
||||||
The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything.
|
The fastest, smallest, fully autonomous AI assistant — deploy anywhere, swap anything.
|
||||||
|
|
||||||
```
|
```
|
||||||
~3MB binary · <10ms startup · 649 tests · 22 providers · Pluggable everything
|
~3MB binary · <10ms startup · 657 tests · 22+ providers · Pluggable everything
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
@ -54,54 +54,13 @@ cargo run --release -- tools test memory_recall '{"query": "Rust"}'
|
||||||
|
|
||||||
Every subsystem is a **trait** — swap implementations with a config change, zero code changes.
|
Every subsystem is a **trait** — swap implementations with a config change, zero code changes.
|
||||||
|
|
||||||
```
|
<p align="center">
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
<img src="docs/architecture.svg" alt="ZeroClaw Architecture" width="800" />
|
||||||
│ ZeroClaw Architecture │
|
</p>
|
||||||
├─────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────────────────────────────────┐ │
|
|
||||||
│ │ Chat Apps │ │ Security Layer │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ Telegram ───┤ │ ┌─────────────┐ ┌──────────────────┐ │ │
|
|
||||||
│ │ Discord ───┤ │ │ Auth Gate │ │ Rate Limiter │ │ │
|
|
||||||
│ │ Slack ───┼───►│ │ │ │ │ │ │
|
|
||||||
│ │ iMessage ───┤ │ │ • allowed_ │ │ • sliding window │ │ │
|
|
||||||
│ │ Matrix ───┤ │ │ users │ │ • max actions/hr │ │ │
|
|
||||||
│ │ CLI ───┤ │ │ • webhook │ │ • max cost/day │ │ │
|
|
||||||
│ │ Webhook ───┤ │ │ secret │ │ │ │ │
|
|
||||||
│ └──────────────┘ │ └──────┬──────┘ └────────┬─────────┘ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ └─────────┼──────────────────┼────────────┘ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ Agent Loop │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Message ──► LLM ──► Tools ──► Reply │ │
|
|
||||||
│ │ ▲ │ │ │
|
|
||||||
│ │ │ ┌─────────────┘ │ │
|
|
||||||
│ │ │ ▼ │ │
|
|
||||||
│ │ ┌──────────────┐ ┌─────────────┐ │ │
|
|
||||||
│ │ │ Context │ │ Sandbox │ │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ │ • Memory │ │ • allowlist │ │ │
|
|
||||||
│ │ │ • Skills │ │ • path jail │ │ │
|
|
||||||
│ │ │ • Workspace │ │ • forbidden │ │ │
|
|
||||||
│ │ │ MD files │ │ paths │ │ │
|
|
||||||
│ │ └──────────────┘ └─────────────┘ │ │
|
|
||||||
│ └──────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ AI Providers (22) │ │
|
|
||||||
│ │ OpenRouter · Anthropic · OpenAI · Mistral · Groq · Venice │ │
|
|
||||||
│ │ Ollama · xAI · DeepSeek · Cerebras · Fireworks · Together │ │
|
|
||||||
│ │ Cloudflare · Moonshot · GLM · MiniMax · Qianfan · + more │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
| Subsystem | Trait | Ships with | Extend |
|
| Subsystem | Trait | Ships with | Extend |
|
||||||
|-----------|-------|------------|--------|
|
|-----------|-------|------------|--------|
|
||||||
| **AI Models** | `Provider` | 22 providers (OpenRouter, Anthropic, OpenAI, Venice, Groq, Mistral, etc.) | Any OpenAI-compatible API |
|
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Venice, Groq, Mistral, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
||||||
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API |
|
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API |
|
||||||
| **Memory** | `Memory` | SQLite (default), Markdown | Any persistence |
|
| **Memory** | `Memory` | SQLite (default), Markdown | Any persistence |
|
||||||
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget | Any capability |
|
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget | Any capability |
|
||||||
|
|
@ -342,7 +301,7 @@ interval_minutes = 30
|
||||||
```bash
|
```bash
|
||||||
cargo build # Dev build
|
cargo build # Dev build
|
||||||
cargo build --release # Release build (~3MB)
|
cargo build --release # Release build (~3MB)
|
||||||
cargo test # 649 tests
|
cargo test # 657 tests
|
||||||
cargo clippy # Lint (0 warnings)
|
cargo clippy # Lint (0 warnings)
|
||||||
|
|
||||||
# Run the SQLite vs Markdown benchmark
|
# Run the SQLite vs Markdown benchmark
|
||||||
|
|
|
||||||
249
docs/architecture.svg
Normal file
249
docs/architecture.svg
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720" font-family="'Segoe UI', system-ui, -apple-system, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-4%" y="-4%" width="108%" height="108%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0d1117"/>
|
||||||
|
<stop offset="100%" stop-color="#161b22"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="agentGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1a1e2e"/>
|
||||||
|
<stop offset="100%" stop-color="#1e2436"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="960" height="720" rx="12" fill="url(#bgGrad)"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text x="480" y="38" text-anchor="middle" fill="#e6edf3" font-size="20" font-weight="700">ZeroClaw Architecture</text>
|
||||||
|
<text x="480" y="58" text-anchor="middle" fill="#7d8590" font-size="12">Zero overhead. Zero compromise. 100% Rust. 100% Agnostic.</text>
|
||||||
|
|
||||||
|
<!-- ═══════════ LEFT: Chat Apps ═══════════ -->
|
||||||
|
<rect x="20" y="90" width="150" height="310" rx="10" fill="#1c2333" stroke="#3b82f6" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="95" y="115" text-anchor="middle" fill="#93c5fd" font-size="13" font-weight="600">Chat Apps</text>
|
||||||
|
|
||||||
|
<!-- Channel icons -->
|
||||||
|
<rect x="35" y="130" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="149" text-anchor="middle" fill="#60a5fa" font-size="11">Telegram</text>
|
||||||
|
|
||||||
|
<rect x="35" y="165" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="184" text-anchor="middle" fill="#818cf8" font-size="11">Discord</text>
|
||||||
|
|
||||||
|
<rect x="35" y="200" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="219" text-anchor="middle" fill="#a78bfa" font-size="11">Slack</text>
|
||||||
|
|
||||||
|
<rect x="35" y="235" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="254" text-anchor="middle" fill="#c084fc" font-size="11">iMessage</text>
|
||||||
|
|
||||||
|
<rect x="35" y="270" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="289" text-anchor="middle" fill="#e879f9" font-size="11">Matrix</text>
|
||||||
|
|
||||||
|
<rect x="35" y="305" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="324" text-anchor="middle" fill="#f472b6" font-size="11">Webhook</text>
|
||||||
|
|
||||||
|
<rect x="35" y="340" width="120" height="28" rx="6" fill="#1e293b"/>
|
||||||
|
<text x="95" y="359" text-anchor="middle" fill="#fb923c" font-size="11">CLI</text>
|
||||||
|
|
||||||
|
<text x="95" y="390" text-anchor="middle" fill="#7d8590" font-size="10" font-style="italic">+ any Channel trait</text>
|
||||||
|
|
||||||
|
<!-- Arrow: Chat Apps → Security -->
|
||||||
|
<line x1="170" y1="245" x2="200" y2="245" stroke="#3b82f6" stroke-width="2" marker-end="url(#arrowBlue)"/>
|
||||||
|
<defs><marker id="arrowBlue" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#3b82f6"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- ═══════════ CENTER: Security Layer ═══════════ -->
|
||||||
|
<rect x="205" y="90" width="180" height="200" rx="10" fill="#1c2333" stroke="#f59e0b" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="295" y="115" text-anchor="middle" fill="#fbbf24" font-size="13" font-weight="600">Security Layer</text>
|
||||||
|
|
||||||
|
<!-- Auth Gate -->
|
||||||
|
<rect x="218" y="130" width="155" height="55" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
||||||
|
<text x="295" y="150" text-anchor="middle" fill="#fbbf24" font-size="11" font-weight="600">Auth Gate</text>
|
||||||
|
<text x="295" y="166" text-anchor="middle" fill="#a8a29e" font-size="9">allowed_users</text>
|
||||||
|
<text x="295" y="178" text-anchor="middle" fill="#a8a29e" font-size="9">webhook_secret</text>
|
||||||
|
|
||||||
|
<!-- Rate Limiter -->
|
||||||
|
<rect x="218" y="195" width="155" height="55" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
||||||
|
<text x="295" y="215" text-anchor="middle" fill="#fbbf24" font-size="11" font-weight="600">Rate Limiter</text>
|
||||||
|
<text x="295" y="231" text-anchor="middle" fill="#a8a29e" font-size="9">sliding window</text>
|
||||||
|
<text x="295" y="243" text-anchor="middle" fill="#a8a29e" font-size="9">max actions/hr & cost/day</text>
|
||||||
|
|
||||||
|
<!-- Arrow: Security → Agent -->
|
||||||
|
<line x1="295" y1="290" x2="295" y2="320" stroke="#f59e0b" stroke-width="2" marker-end="url(#arrowAmber)"/>
|
||||||
|
<defs><marker id="arrowAmber" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#f59e0b"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- ═══════════ CENTER: Agent Loop ═══════════ -->
|
||||||
|
<rect x="205" y="325" width="530" height="200" rx="12" fill="url(#agentGrad)" stroke="#22c55e" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="470" y="350" text-anchor="middle" fill="#4ade80" font-size="14" font-weight="700">Agent Loop</text>
|
||||||
|
|
||||||
|
<!-- Message box -->
|
||||||
|
<rect x="225" y="365" width="95" height="45" rx="8" fill="#1a2e1a" stroke="#4ade80" stroke-width="1"/>
|
||||||
|
<text x="272" y="385" text-anchor="middle" fill="#4ade80" font-size="10" font-weight="600">Message</text>
|
||||||
|
<text x="272" y="400" text-anchor="middle" fill="#86efac" font-size="18">💬</text>
|
||||||
|
|
||||||
|
<!-- LLM box -->
|
||||||
|
<rect x="345" y="365" width="95" height="45" rx="8" fill="#1a2e1a" stroke="#4ade80" stroke-width="1"/>
|
||||||
|
<text x="392" y="385" text-anchor="middle" fill="#4ade80" font-size="10" font-weight="600">LLM</text>
|
||||||
|
<text x="392" y="400" text-anchor="middle" fill="#86efac" font-size="18">🤖</text>
|
||||||
|
|
||||||
|
<!-- Tools box -->
|
||||||
|
<rect x="465" y="365" width="95" height="45" rx="8" fill="#1a2e1a" stroke="#4ade80" stroke-width="1"/>
|
||||||
|
<text x="512" y="385" text-anchor="middle" fill="#4ade80" font-size="10" font-weight="600">Tools</text>
|
||||||
|
<text x="512" y="400" text-anchor="middle" fill="#86efac" font-size="18">🛠️</text>
|
||||||
|
|
||||||
|
<!-- Response box -->
|
||||||
|
<rect x="585" y="365" width="95" height="45" rx="8" fill="#1a2e1a" stroke="#4ade80" stroke-width="1"/>
|
||||||
|
<text x="632" y="385" text-anchor="middle" fill="#4ade80" font-size="10" font-weight="600">Response</text>
|
||||||
|
<text x="632" y="400" text-anchor="middle" fill="#86efac" font-size="18">💬</text>
|
||||||
|
|
||||||
|
<!-- Agent loop arrows -->
|
||||||
|
<line x1="320" y1="387" x2="345" y2="387" stroke="#4ade80" stroke-width="1.5" marker-end="url(#arrowGreen)"/>
|
||||||
|
<line x1="440" y1="387" x2="465" y2="387" stroke="#4ade80" stroke-width="1.5" marker-end="url(#arrowGreen)"/>
|
||||||
|
<line x1="560" y1="387" x2="585" y2="387" stroke="#4ade80" stroke-width="1.5" marker-end="url(#arrowGreen)"/>
|
||||||
|
<defs><marker id="arrowGreen" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#4ade80"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- Context box -->
|
||||||
|
<rect x="225" y="430" width="145" height="80" rx="8" fill="#1e1a2e" stroke="#a78bfa" stroke-width="1"/>
|
||||||
|
<text x="297" y="450" text-anchor="middle" fill="#c4b5fd" font-size="11" font-weight="600">Context</text>
|
||||||
|
<text x="297" y="468" text-anchor="middle" fill="#8b5cf6" font-size="9">Memory (SQLite/MD)</text>
|
||||||
|
<text x="297" y="482" text-anchor="middle" fill="#8b5cf6" font-size="9">Skills & Workspace</text>
|
||||||
|
<text x="297" y="496" text-anchor="middle" fill="#8b5cf6" font-size="9">IDENTITY.md / AGENTS.md</text>
|
||||||
|
|
||||||
|
<!-- Sandbox box -->
|
||||||
|
<rect x="390" y="430" width="145" height="80" rx="8" fill="#2e1a1a" stroke="#ef4444" stroke-width="1"/>
|
||||||
|
<text x="462" y="450" text-anchor="middle" fill="#fca5a5" font-size="11" font-weight="600">Sandbox</text>
|
||||||
|
<text x="462" y="468" text-anchor="middle" fill="#ef4444" font-size="9">Command allowlist</text>
|
||||||
|
<text x="462" y="482" text-anchor="middle" fill="#ef4444" font-size="9">Path jail & traversal block</text>
|
||||||
|
<text x="462" y="496" text-anchor="middle" fill="#ef4444" font-size="9">Autonomy levels</text>
|
||||||
|
|
||||||
|
<!-- Cron box -->
|
||||||
|
<rect x="555" y="430" width="145" height="80" rx="8" fill="#1a2e2e" stroke="#06b6d4" stroke-width="1"/>
|
||||||
|
<text x="627" y="450" text-anchor="middle" fill="#67e8f9" font-size="11" font-weight="600">Heartbeat & Cron</text>
|
||||||
|
<text x="627" y="468" text-anchor="middle" fill="#22d3ee" font-size="9">HEARTBEAT.md tasks</text>
|
||||||
|
<text x="627" y="482" text-anchor="middle" fill="#22d3ee" font-size="9">Scheduled actions</text>
|
||||||
|
<text x="627" y="496" text-anchor="middle" fill="#22d3ee" font-size="9">Periodic check-ins</text>
|
||||||
|
|
||||||
|
<!-- ═══════════ RIGHT: Tunnel ═══════════ -->
|
||||||
|
<rect x="400" y="90" width="180" height="200" rx="10" fill="#1c2333" stroke="#06b6d4" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="490" y="115" text-anchor="middle" fill="#67e8f9" font-size="13" font-weight="600">Agnostic Tunnel</text>
|
||||||
|
<text x="490" y="132" text-anchor="middle" fill="#7d8590" font-size="9">Bring Your Own</text>
|
||||||
|
|
||||||
|
<rect x="413" y="145" width="155" height="24" rx="5" fill="#1e293b"/>
|
||||||
|
<text x="490" y="162" text-anchor="middle" fill="#22d3ee" font-size="10">Cloudflare</text>
|
||||||
|
|
||||||
|
<rect x="413" y="175" width="155" height="24" rx="5" fill="#1e293b"/>
|
||||||
|
<text x="490" y="192" text-anchor="middle" fill="#22d3ee" font-size="10">Tailscale</text>
|
||||||
|
|
||||||
|
<rect x="413" y="205" width="155" height="24" rx="5" fill="#1e293b"/>
|
||||||
|
<text x="490" y="222" text-anchor="middle" fill="#22d3ee" font-size="10">ngrok</text>
|
||||||
|
|
||||||
|
<rect x="413" y="235" width="155" height="24" rx="5" fill="#1e293b"/>
|
||||||
|
<text x="490" y="252" text-anchor="middle" fill="#22d3ee" font-size="10">Custom (bore, frp, ssh...)</text>
|
||||||
|
|
||||||
|
<text x="490" y="280" text-anchor="middle" fill="#7d8590" font-size="10" font-style="italic">+ any Tunnel trait</text>
|
||||||
|
|
||||||
|
<!-- Arrow: Tunnel → Gateway/Agent -->
|
||||||
|
<line x1="490" y1="290" x2="490" y2="320" stroke="#06b6d4" stroke-width="2" marker-end="url(#arrowCyan)"/>
|
||||||
|
<defs><marker id="arrowCyan" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#06b6d4"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- ═══════════ FAR RIGHT: AI Providers ═══════════ -->
|
||||||
|
<rect x="600" y="90" width="340" height="230" rx="10" fill="#1c2333" stroke="#22c55e" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="770" y="115" text-anchor="middle" fill="#4ade80" font-size="13" font-weight="600">AI Providers (22+)</text>
|
||||||
|
<text x="770" y="132" text-anchor="middle" fill="#7d8590" font-size="9">Any OpenAI-compatible API</text>
|
||||||
|
|
||||||
|
<!-- Provider grid -->
|
||||||
|
<rect x="615" y="145" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="665" y="160" text-anchor="middle" fill="#86efac" font-size="9">OpenRouter</text>
|
||||||
|
|
||||||
|
<rect x="725" y="145" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="775" y="160" text-anchor="middle" fill="#86efac" font-size="9">Anthropic</text>
|
||||||
|
|
||||||
|
<rect x="835" y="145" width="95" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="882" y="160" text-anchor="middle" fill="#86efac" font-size="9">OpenAI</text>
|
||||||
|
|
||||||
|
<rect x="615" y="173" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="665" y="188" text-anchor="middle" fill="#86efac" font-size="9">Venice</text>
|
||||||
|
|
||||||
|
<rect x="725" y="173" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="775" y="188" text-anchor="middle" fill="#86efac" font-size="9">Groq</text>
|
||||||
|
|
||||||
|
<rect x="835" y="173" width="95" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="882" y="188" text-anchor="middle" fill="#86efac" font-size="9">Mistral</text>
|
||||||
|
|
||||||
|
<rect x="615" y="201" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="665" y="216" text-anchor="middle" fill="#86efac" font-size="9">DeepSeek</text>
|
||||||
|
|
||||||
|
<rect x="725" y="201" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="775" y="216" text-anchor="middle" fill="#86efac" font-size="9">xAI / Grok</text>
|
||||||
|
|
||||||
|
<rect x="835" y="201" width="95" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="882" y="216" text-anchor="middle" fill="#86efac" font-size="9">Ollama</text>
|
||||||
|
|
||||||
|
<rect x="615" y="229" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="665" y="244" text-anchor="middle" fill="#86efac" font-size="9">Together AI</text>
|
||||||
|
|
||||||
|
<rect x="725" y="229" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="775" y="244" text-anchor="middle" fill="#86efac" font-size="9">Fireworks</text>
|
||||||
|
|
||||||
|
<rect x="835" y="229" width="95" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="882" y="244" text-anchor="middle" fill="#86efac" font-size="9">Perplexity</text>
|
||||||
|
|
||||||
|
<rect x="615" y="257" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="665" y="272" text-anchor="middle" fill="#86efac" font-size="9">Cloudflare AI</text>
|
||||||
|
|
||||||
|
<rect x="725" y="257" width="100" height="22" rx="5" fill="#1a2e1a"/>
|
||||||
|
<text x="775" y="272" text-anchor="middle" fill="#86efac" font-size="9">Cohere</text>
|
||||||
|
|
||||||
|
<rect x="835" y="257" width="95" height="22" rx="5" fill="#1e3a1e" stroke="#4ade80" stroke-width="1" stroke-dasharray="3,2"/>
|
||||||
|
<text x="882" y="272" text-anchor="middle" fill="#4ade80" font-size="9" font-weight="600">Custom</text>
|
||||||
|
|
||||||
|
<text x="770" y="300" text-anchor="middle" fill="#7d8590" font-size="10" font-style="italic">+ any Provider trait / custom:URL</text>
|
||||||
|
|
||||||
|
<!-- Arrow: Providers → Agent LLM -->
|
||||||
|
<line x1="770" y1="320" x2="440" y2="370" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowGreen)"/>
|
||||||
|
|
||||||
|
<!-- ═══════════ BOTTOM: Setup Wizard ═══════════ -->
|
||||||
|
<rect x="20" y="545" width="920" height="155" rx="10" fill="#1c2333" stroke="#f472b6" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
|
<text x="480" y="572" text-anchor="middle" fill="#f9a8d4" font-size="14" font-weight="700">Setup Wizard — zeroclaw onboard</text>
|
||||||
|
<text x="480" y="590" text-anchor="middle" fill="#7d8590" font-size="11">6 steps, under 60 seconds, so easy a 5-year-old can do it</text>
|
||||||
|
|
||||||
|
<!-- Wizard steps -->
|
||||||
|
<rect x="35" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="100" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">1. Workspace</text>
|
||||||
|
<text x="100" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">~/.zeroclaw/</text>
|
||||||
|
|
||||||
|
<rect x="175" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="240" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">2. AI Provider</text>
|
||||||
|
<text x="240" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">22+ or custom URL</text>
|
||||||
|
|
||||||
|
<rect x="315" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="380" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">3. Channels</text>
|
||||||
|
<text x="380" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">7 channels + test</text>
|
||||||
|
|
||||||
|
<rect x="455" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="520" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">4. Tunnel</text>
|
||||||
|
<text x="520" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">5 providers or skip</text>
|
||||||
|
|
||||||
|
<rect x="595" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="660" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">5. Personalize</text>
|
||||||
|
<text x="660" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">name, style, timezone</text>
|
||||||
|
|
||||||
|
<rect x="735" y="605" width="130" height="42" rx="8" fill="#2d1f3d" stroke="#c084fc" stroke-width="1"/>
|
||||||
|
<text x="800" y="622" text-anchor="middle" fill="#e9d5ff" font-size="9" font-weight="600">6. Scaffold</text>
|
||||||
|
<text x="800" y="638" text-anchor="middle" fill="#a78bfa" font-size="8">workspace MD files</text>
|
||||||
|
|
||||||
|
<!-- Step arrows -->
|
||||||
|
<line x1="165" y1="626" x2="175" y2="626" stroke="#c084fc" stroke-width="1" marker-end="url(#arrowPurple)"/>
|
||||||
|
<line x1="305" y1="626" x2="315" y2="626" stroke="#c084fc" stroke-width="1" marker-end="url(#arrowPurple)"/>
|
||||||
|
<line x1="445" y1="626" x2="455" y2="626" stroke="#c084fc" stroke-width="1" marker-end="url(#arrowPurple)"/>
|
||||||
|
<line x1="585" y1="626" x2="595" y2="626" stroke="#c084fc" stroke-width="1" marker-end="url(#arrowPurple)"/>
|
||||||
|
<line x1="725" y1="626" x2="735" y2="626" stroke="#c084fc" stroke-width="1" marker-end="url(#arrowPurple)"/>
|
||||||
|
<defs><marker id="arrowPurple" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#c084fc"/></marker></defs>
|
||||||
|
|
||||||
|
<!-- Ready badge -->
|
||||||
|
<rect x="340" y="660" width="280" height="28" rx="14" fill="#166534" stroke="#4ade80" stroke-width="1"/>
|
||||||
|
<text x="480" y="679" text-anchor="middle" fill="#4ade80" font-size="12" font-weight="600">✅ Ready — zeroclaw agent</text>
|
||||||
|
|
||||||
|
<!-- Footer stats -->
|
||||||
|
<text x="480" y="712" text-anchor="middle" fill="#7d8590" font-size="10">~3MB binary · <10ms startup · 657 tests · 22+ providers · 7 channels · 5 tunnels · Pluggable everything</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -55,19 +55,22 @@ pub fn run_wizard() -> Result<Config> {
|
||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
print_step(1, 5, "Workspace Setup");
|
print_step(1, 6, "Workspace Setup");
|
||||||
let (workspace_dir, config_path) = setup_workspace()?;
|
let (workspace_dir, config_path) = setup_workspace()?;
|
||||||
|
|
||||||
print_step(2, 5, "AI Provider & API Key");
|
print_step(2, 6, "AI Provider & API Key");
|
||||||
let (provider, api_key, model) = setup_provider()?;
|
let (provider, api_key, model) = setup_provider()?;
|
||||||
|
|
||||||
print_step(3, 5, "Channels (How You Talk to ZeroClaw)");
|
print_step(3, 6, "Channels (How You Talk to ZeroClaw)");
|
||||||
let channels_config = setup_channels()?;
|
let channels_config = setup_channels()?;
|
||||||
|
|
||||||
print_step(4, 5, "Project Context (Personalize Your Agent)");
|
print_step(4, 6, "Tunnel (Expose to Internet)");
|
||||||
|
let tunnel_config = setup_tunnel()?;
|
||||||
|
|
||||||
|
print_step(5, 6, "Project Context (Personalize Your Agent)");
|
||||||
let project_ctx = setup_project_context()?;
|
let project_ctx = setup_project_context()?;
|
||||||
|
|
||||||
print_step(5, 5, "Workspace Files");
|
print_step(6, 6, "Workspace Files");
|
||||||
scaffold_workspace(&workspace_dir, &project_ctx)?;
|
scaffold_workspace(&workspace_dir, &project_ctx)?;
|
||||||
|
|
||||||
// ── Build config ──
|
// ── Build config ──
|
||||||
|
|
@ -93,7 +96,7 @@ pub fn run_wizard() -> Result<Config> {
|
||||||
heartbeat: HeartbeatConfig::default(),
|
heartbeat: HeartbeatConfig::default(),
|
||||||
channels_config,
|
channels_config,
|
||||||
memory: MemoryConfig::default(), // SQLite + auto-save by default
|
memory: MemoryConfig::default(), // SQLite + auto-save by default
|
||||||
tunnel: crate::config::TunnelConfig::default(),
|
tunnel: tunnel_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -208,11 +211,12 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
|
||||||
fn setup_provider() -> Result<(String, String, String)> {
|
fn setup_provider() -> Result<(String, String, String)> {
|
||||||
// ── Tier selection ──
|
// ── Tier selection ──
|
||||||
let tiers = vec![
|
let tiers = vec![
|
||||||
"Recommended (OpenRouter, Venice, Anthropic, OpenAI)",
|
"⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI)",
|
||||||
"Fast inference (Groq, Fireworks, Together AI)",
|
"⚡ Fast inference (Groq, Fireworks, Together AI)",
|
||||||
"Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
|
"🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
|
||||||
"Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)",
|
"🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)",
|
||||||
"Local / private (Ollama — no API key needed)",
|
"🏠 Local / private (Ollama — no API key needed)",
|
||||||
|
"🔧 Custom — bring your own OpenAI-compatible API",
|
||||||
];
|
];
|
||||||
|
|
||||||
let tier_idx = Select::new()
|
let tier_idx = Select::new()
|
||||||
|
|
@ -255,9 +259,53 @@ fn setup_provider() -> Result<(String, String, String)> {
|
||||||
("opencode", "OpenCode Zen — code-focused AI"),
|
("opencode", "OpenCode Zen — code-focused AI"),
|
||||||
("cohere", "Cohere — Command R+ & embeddings"),
|
("cohere", "Cohere — Command R+ & embeddings"),
|
||||||
],
|
],
|
||||||
_ => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")],
|
4 => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")],
|
||||||
|
_ => vec![], // Custom — handled below
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Custom / BYOP flow ──
|
||||||
|
if providers.is_empty() {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("Custom Provider Setup").white().bold(),
|
||||||
|
style("— any OpenAI-compatible API").dim()
|
||||||
|
);
|
||||||
|
print_bullet("ZeroClaw works with ANY API that speaks the OpenAI chat completions format.");
|
||||||
|
print_bullet("Examples: LiteLLM, LocalAI, vLLM, text-generation-webui, LM Studio, etc.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let base_url: String = Input::new()
|
||||||
|
.with_prompt(" API base URL (e.g. http://localhost:1234 or https://my-api.com)")
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let base_url = base_url.trim().trim_end_matches('/').to_string();
|
||||||
|
if base_url.is_empty() {
|
||||||
|
anyhow::bail!("Custom provider requires a base URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key: String = Input::new()
|
||||||
|
.with_prompt(" API key (or Enter to skip if not needed)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let model: String = Input::new()
|
||||||
|
.with_prompt(" Model name (e.g. llama3, gpt-4o, mistral)")
|
||||||
|
.default("default".into())
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
let provider_name = format!("custom:{base_url}");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {} Provider: {} | Model: {}",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style(&provider_name).green(),
|
||||||
|
style(&model).green()
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok((provider_name, api_key, model));
|
||||||
|
}
|
||||||
|
|
||||||
let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect();
|
let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect();
|
||||||
|
|
||||||
let provider_idx = Select::new()
|
let provider_idx = Select::new()
|
||||||
|
|
@ -1055,6 +1103,159 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Tunnel ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn setup_tunnel() -> Result<crate::config::TunnelConfig> {
|
||||||
|
use crate::config::schema::{
|
||||||
|
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
|
||||||
|
TunnelConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
print_bullet("A tunnel exposes your gateway to the internet securely.");
|
||||||
|
print_bullet("Skip this if you only use CLI or local channels.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let options = vec![
|
||||||
|
"Skip — local only (default)",
|
||||||
|
"Cloudflare Tunnel — Zero Trust, free tier",
|
||||||
|
"Tailscale — private tailnet or public Funnel",
|
||||||
|
"ngrok — instant public URLs",
|
||||||
|
"Custom — bring your own (bore, frp, ssh, etc.)",
|
||||||
|
];
|
||||||
|
|
||||||
|
let choice = Select::new()
|
||||||
|
.with_prompt(" Select tunnel provider")
|
||||||
|
.items(&options)
|
||||||
|
.default(0)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
let config = match choice {
|
||||||
|
1 => {
|
||||||
|
println!();
|
||||||
|
print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard.");
|
||||||
|
let token: String = Input::new()
|
||||||
|
.with_prompt(" Cloudflare tunnel token")
|
||||||
|
.interact_text()?;
|
||||||
|
if token.trim().is_empty() {
|
||||||
|
println!(" {} Skipped", style("→").dim());
|
||||||
|
TunnelConfig::default()
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} Tunnel: {}",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style("Cloudflare").green()
|
||||||
|
);
|
||||||
|
TunnelConfig {
|
||||||
|
provider: "cloudflare".into(),
|
||||||
|
cloudflare: Some(CloudflareTunnelConfig { token }),
|
||||||
|
..TunnelConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
println!();
|
||||||
|
print_bullet("Tailscale must be installed and authenticated (tailscale up).");
|
||||||
|
let funnel = Confirm::new()
|
||||||
|
.with_prompt(" Use Funnel (public internet)? No = tailnet only")
|
||||||
|
.default(false)
|
||||||
|
.interact()?;
|
||||||
|
println!(
|
||||||
|
" {} Tunnel: {} ({})",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style("Tailscale").green(),
|
||||||
|
if funnel {
|
||||||
|
"Funnel — public"
|
||||||
|
} else {
|
||||||
|
"Serve — tailnet only"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TunnelConfig {
|
||||||
|
provider: "tailscale".into(),
|
||||||
|
tailscale: Some(TailscaleTunnelConfig {
|
||||||
|
funnel,
|
||||||
|
hostname: None,
|
||||||
|
}),
|
||||||
|
..TunnelConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
println!();
|
||||||
|
print_bullet(
|
||||||
|
"Get your auth token at https://dashboard.ngrok.com/get-started/your-authtoken",
|
||||||
|
);
|
||||||
|
let auth_token: String = Input::new()
|
||||||
|
.with_prompt(" ngrok auth token")
|
||||||
|
.interact_text()?;
|
||||||
|
if auth_token.trim().is_empty() {
|
||||||
|
println!(" {} Skipped", style("→").dim());
|
||||||
|
TunnelConfig::default()
|
||||||
|
} else {
|
||||||
|
let domain: String = Input::new()
|
||||||
|
.with_prompt(" Custom domain (optional, Enter to skip)")
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
println!(
|
||||||
|
" {} Tunnel: {}",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style("ngrok").green()
|
||||||
|
);
|
||||||
|
TunnelConfig {
|
||||||
|
provider: "ngrok".into(),
|
||||||
|
ngrok: Some(NgrokTunnelConfig {
|
||||||
|
auth_token,
|
||||||
|
domain: if domain.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(domain)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
..TunnelConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => {
|
||||||
|
println!();
|
||||||
|
print_bullet("Enter the command to start your tunnel.");
|
||||||
|
print_bullet("Use {port} and {host} as placeholders.");
|
||||||
|
print_bullet("Example: bore local {port} --to bore.pub");
|
||||||
|
let cmd: String = Input::new()
|
||||||
|
.with_prompt(" Start command")
|
||||||
|
.interact_text()?;
|
||||||
|
if cmd.trim().is_empty() {
|
||||||
|
println!(" {} Skipped", style("→").dim());
|
||||||
|
TunnelConfig::default()
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} Tunnel: {} ({})",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style("Custom").green(),
|
||||||
|
style(&cmd).dim()
|
||||||
|
);
|
||||||
|
TunnelConfig {
|
||||||
|
provider: "custom".into(),
|
||||||
|
custom: Some(CustomTunnelConfig {
|
||||||
|
start_command: cmd,
|
||||||
|
health_url: None,
|
||||||
|
url_pattern: None,
|
||||||
|
}),
|
||||||
|
..TunnelConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!(
|
||||||
|
" {} Tunnel: {}",
|
||||||
|
style("✓").green().bold(),
|
||||||
|
style("none (local only)").dim()
|
||||||
|
);
|
||||||
|
TunnelConfig::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 6: Scaffold workspace files ─────────────────────────────
|
// ── Step 6: Scaffold workspace files ─────────────────────────────
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,24 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
||||||
"Cohere", "https://api.cohere.com/compatibility", api_key, AuthStyle::Bearer,
|
"Cohere", "https://api.cohere.com/compatibility", api_key, AuthStyle::Bearer,
|
||||||
))),
|
))),
|
||||||
|
|
||||||
|
// ── Bring Your Own Provider (custom URL) ───────────
|
||||||
|
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
|
||||||
|
name if name.starts_with("custom:") => {
|
||||||
|
let base_url = name.strip_prefix("custom:").unwrap_or("");
|
||||||
|
if base_url.is_empty() {
|
||||||
|
anyhow::bail!("Custom provider requires a URL. Format: custom:https://your-api.com");
|
||||||
|
}
|
||||||
|
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||||
|
"Custom",
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
AuthStyle::Bearer,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
_ => anyhow::bail!(
|
_ => anyhow::bail!(
|
||||||
"Unknown provider: {name}. Run `zeroclaw integrations list -c ai` to see all available providers."
|
"Unknown provider: {name}. Run `zeroclaw integrations list -c ai` to see all available providers.\n\
|
||||||
|
Tip: Use \"custom:https://your-api.com\" for any OpenAI-compatible endpoint."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +247,37 @@ mod tests {
|
||||||
assert!(create_provider("cohere", Some("key")).is_ok());
|
assert!(create_provider("cohere", Some("key")).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom / BYOP provider ─────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_custom_url() {
|
||||||
|
let p = create_provider("custom:https://my-llm.example.com", Some("key"));
|
||||||
|
assert!(p.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_custom_localhost() {
|
||||||
|
let p = create_provider("custom:http://localhost:1234", Some("key"));
|
||||||
|
assert!(p.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_custom_no_key() {
|
||||||
|
let p = create_provider("custom:https://my-llm.example.com", None);
|
||||||
|
assert!(p.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_custom_empty_url_errors() {
|
||||||
|
match create_provider("custom:", None) {
|
||||||
|
Err(e) => assert!(
|
||||||
|
e.to_string().contains("requires a URL"),
|
||||||
|
"Expected 'requires a URL', got: {e}"
|
||||||
|
),
|
||||||
|
Ok(_) => panic!("Expected error for empty custom URL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Error cases ──────────────────────────────────────────
|
// ── Error cases ──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue