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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,15 @@ coding = "ollama/qwen3"

```toml
[llm.provider.my-provider]
api_type = "openai_completions" # or "anthropic"
api_type = "openai_completions" # or "openai_chat_completions", "openai_responses", "anthropic"
base_url = "https://my-llm-host.example.com"
api_key = "env:MY_PROVIDER_KEY"

[defaults.routing]
channel = "my-provider/my-model"
```

Additional built-in providers include **NVIDIA**, **MiniMax**, **Moonshot AI (Kimi)**, and **Z.AI Coding Plan** — configure with `nvidia_key`, `minimax_key`, `moonshot_key`, or `zai_coding_plan_key` in `[llm]`.
Additional built-in providers include **Kilo Gateway**, **OpenCode Go**, **NVIDIA**, **MiniMax**, **Moonshot AI (Kimi)**, and **Z.AI Coding Plan** — configure with `kilo_key`, `opencode_go_key`, `nvidia_key`, `minimax_key`, `moonshot_key`, or `zai_coding_plan_key` in `[llm]`.

### Skills

Expand Down Expand Up @@ -381,7 +381,7 @@ Read the full vision in the [roadmap](docs/content/docs/(deployment)/roadmap.mdx
### Prerequisites

- **Rust** 1.85+ ([rustup](https://rustup.rs/))
- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, NVIDIA, MiniMax, Moonshot AI, OpenCode Zen) — or use `spacebot auth login` for Anthropic OAuth
- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Kilo Gateway, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, NVIDIA, MiniMax, Moonshot AI, OpenCode Zen, OpenCode Go) — or use `spacebot auth login` for Anthropic OAuth

### Build and Run

Expand Down
15 changes: 11 additions & 4 deletions docs/content/docs/(configuration)/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ spacebot --config /path/to.toml # CLI override
anthropic_key = "env:ANTHROPIC_API_KEY"
openai_key = "env:OPENAI_API_KEY"
openrouter_key = "env:OPENROUTER_API_KEY"
kilo_key = "env:KILO_API_KEY"
zhipu_key = "env:ZHIPU_API_KEY"
groq_key = "env:GROQ_API_KEY"
together_key = "env:TOGETHER_API_KEY"
Expand All @@ -32,6 +33,7 @@ deepseek_key = "env:DEEPSEEK_API_KEY"
xai_key = "env:XAI_API_KEY"
mistral_key = "env:MISTRAL_API_KEY"
opencode_zen_key = "env:OPENCODE_ZEN_API_KEY"
opencode_go_key = "env:OPENCODE_GO_API_KEY"

# Custom LLM providers (alternative to legacy keys)
[llm.provider.my_anthropic]
Expand Down Expand Up @@ -173,7 +175,7 @@ anthropic_key = "env:ANTHROPIC_API_KEY"

This reads `ANTHROPIC_API_KEY` from the environment at startup. If the variable is unset, the value is treated as missing.

LLM keys also have implicit env fallbacks — if no key is set in the TOML, Spacebot checks `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `OPENROUTER_API_KEY` automatically.
LLM keys also have implicit env fallbacks — if no key is set in the TOML, Spacebot checks `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `KILO_API_KEY`, and `OPENCODE_GO_API_KEY` automatically.

## Env-Only Mode

Expand Down Expand Up @@ -208,6 +210,7 @@ Model names include the provider as a prefix:
| Anthropic | `anthropic/<model>` | `anthropic/claude-sonnet-4-20250514` |
| OpenAI | `openai/<model>` | `openai/gpt-4o` |
| OpenRouter | `openrouter/<provider>/<model>` | `openrouter/anthropic/claude-sonnet-4-20250514` |
| Kilo Gateway | `kilo/<provider>/<model>` | `kilo/anthropic/claude-sonnet-4.5` |
| Custom provider | `<provider_id>/<model>` | `my_openai/gpt-4o-mini` |

You can mix providers across process types. See [Routing](/docs/routing) for the full routing system.
Expand Down Expand Up @@ -329,6 +332,7 @@ If you define a custom provider with the same ID as a legacy key, your custom co
| `anthropic_key` | string | None | Anthropic API key (or `env:VAR_NAME`) |
| `openai_key` | string | None | OpenAI API key (or `env:VAR_NAME`) |
| `openrouter_key` | string | None | OpenRouter API key (or `env:VAR_NAME`) |
| `kilo_key` | string | None | Kilo Gateway API key (or `env:VAR_NAME`) |
| `zhipu_key` | string | None | Zhipu AI (GLM) API key (or `env:VAR_NAME`) |
| `groq_key` | string | None | Groq API key (or `env:VAR_NAME`) |
| `together_key` | string | None | Together AI API key (or `env:VAR_NAME`) |
Expand All @@ -337,31 +341,34 @@ If you define a custom provider with the same ID as a legacy key, your custom co
| `xai_key` | string | None | XAI API key (or `env:VAR_NAME`) |
| `mistral_key` | string | None | Mistral API key (or `env:VAR_NAME`) |
| `opencode_zen_key` | string | None | OpenCode Zen API key (or `env:VAR_NAME`) |
| `opencode_go_key` | string | None | OpenCode Go API key (or `env:VAR_NAME`) |

#### Custom Providers

Custom providers allow configuring LLM providers with custom endpoints and API types. Use either legacy keys **or** custom providers.

```toml
[llm.provider.<id>]
api_type = "anthropic" # Required - one of: anthropic, openai_completions, openai_responses
api_type = "anthropic" # Required - see supported values below
base_url = "https://api..." # Required - valid URL
api_key = "env:API_KEY" # Required - API key (supports env:VAR_NAME format)
name = "My Provider" # Optional - friendly name for display
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `api_type` | string | Yes | API protocol type. One of: `anthropic` (Anthropic Messages API), `openai_completions` (OpenAI Chat Completions-compatible API), or `openai_responses` (OpenAI Responses API-compatible) |
| `api_type` | string | Yes | API protocol type. One of: `anthropic`, `openai_completions`, `openai_chat_completions`, `openai_responses`, `gemini`, or `kilo_gateway` |
| `base_url` | string | Yes | Base URL of the API endpoint. Must be a valid URL (including protocol) |
| `api_key` | string | Yes | API key for authentication. Supports `env:VAR_NAME` syntax to reference environment variables |
| `name` | string | No | Optional friendly name for the provider (displayed in logs and UI) |

> Note:
> - For `openai_completions` and `openai_responses`, configure `base_url` as the provider root URL (usually without a trailing `/v1`).
> - For `openai_completions`, `openai_chat_completions`, and `openai_responses`, configure `base_url` as the provider root URL (usually without a trailing `/v1`).
> - Spacebot appends the endpoint path automatically:
> - `openai_completions` -> `/v1/chat/completions`
> - `openai_chat_completions` -> `/chat/completions`
> - `openai_responses` -> `/v1/responses`
> - `kilo_gateway` -> `/chat/completions` plus Kilo-required `HTTP-Referer` / `X-Title` headers
> - If you include `/v1` in `base_url`, requests can end up with duplicated paths such as `/v1/v1/...`.

**Provider ID Requirements:**
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/(deployment)/roadmap.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The full message-in → LLM → response-out pipeline is wired end-to-end across
- **Config** — hierarchical TOML with `Config`, `AgentConfig`, `ResolvedAgentConfig`, `Binding`, `MessagingConfig`. File watcher with event filtering and content hash debounce for hot-reload.
- **Multi-agent** — per-agent database isolation, `Agent` struct bundles all dependencies
- **Database connections** — SQLite + LanceDB + redb per-agent, migrations for all tables
- **LLM** — `SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 11 providers (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen)
- **LLM** — `SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 13 providers (Anthropic, OpenAI, OpenRouter, Kilo Gateway, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen, OpenCode Go)
- **Model routing** — `RoutingConfig` with process-type defaults, task overrides, fallback chains
- **Memory** — full stack: types, SQLite store (CRUD + graph), LanceDB (embeddings + vector + FTS), fastembed, hybrid search (RRF fusion). `memory_type` filter wired end-to-end through SearchConfig. `total_cmp` for safe sorting.
- **Memory maintenance** — decay + prune implemented
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/(getting-started)/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ You can also manage updates from **Settings → Updates** in the web UI.

- **Rust 1.85+** — `rustup update stable`
- **Bun** (optional, for the web UI) — `curl -fsSL https://bun.sh/install | bash`
- **An LLM API key** — Anthropic, OpenAI, or OpenRouter
- **An LLM API key** — Anthropic, OpenAI, OpenRouter, Kilo Gateway, or OpenCode Go

### Install

Expand Down Expand Up @@ -88,6 +88,8 @@ Create `~/.spacebot/config.toml`:
[llm]
anthropic_key = "sk-ant-..."
# or: openrouter_key = "sk-or-..."
# or: kilo_key = "sk-..."
# or: opencode_go_key = "..."
# or: openai_key = "sk-..."
# Keys also support env references: anthropic_key = "env:ANTHROPIC_API_KEY"

Expand Down
2 changes: 2 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ export interface ProviderStatus {
openai: boolean;
openai_chatgpt: boolean;
openrouter: boolean;
kilo: boolean;
zhipu: boolean;
groq: boolean;
together: boolean;
Expand All @@ -736,6 +737,7 @@ export interface ProviderStatus {
gemini: boolean;
ollama: boolean;
opencode_zen: boolean;
opencode_go: boolean;
nvidia: boolean;
minimax: boolean;
minimax_cn: boolean;
Expand Down
11 changes: 9 additions & 2 deletions interface/src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface ModelSelectProps {
const PROVIDER_LABELS: Record<string, string> = {
anthropic: "Anthropic",
openrouter: "OpenRouter",
kilo: "Kilo Gateway",
openai: "OpenAI",
"openai-chatgpt": "ChatGPT Plus (OAuth)",
deepseek: "DeepSeek",
Expand All @@ -27,6 +28,7 @@ const PROVIDER_LABELS: Record<string, string> = {
zhipu: "Z.ai (GLM)",
ollama: "Ollama",
"opencode-zen": "OpenCode Zen",
"opencode-go": "OpenCode Go",
minimax: "MiniMax",
"minimax-cn": "MiniMax CN",
};
Expand Down Expand Up @@ -128,6 +130,7 @@ export function ModelSelect({

const providerOrder = [
"openrouter",
"kilo",
"anthropic",
"openai",
"openai-chatgpt",
Expand All @@ -141,12 +144,16 @@ export function ModelSelect({
"fireworks",
"zhipu",
"opencode-zen",
"opencode-go",
"minimax",
"minimax-cn",
];
const providerRank = (provider: string) => {
const index = providerOrder.indexOf(provider);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
const sortedProviders = Object.keys(grouped).sort(
(a, b) =>
(providerOrder.indexOf(a) ?? 99) - (providerOrder.indexOf(b) ?? 99),
(a, b) => providerRank(a) - providerRank(b),
);

return (
Expand Down
22 changes: 22 additions & 0 deletions interface/src/lib/providerIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ function OllamaIcon({ size = 24, className }: IconProps) {
);
}

function KiloIcon({ size = 24, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M0,0v100h100V0H0ZM92.5925926,92.5925926H7.4074074V7.4074074h85.1851852v85.1851852ZM61.1111044,71.9096084h9.2592593v7.4074074h-11.6402116l-5.026455-5.026455v-11.6402116h7.4074074v9.2592593ZM77.7777711,71.9096084h-7.4074074v-9.2592593h-9.2592593v-7.4074074h11.6402116l5.026455,5.026455v11.6402116ZM46.2962963,61.1114207h-7.4074074v-7.4074074h7.4074074v7.4074074ZM22.2222222,53.7040133h7.4074074v16.6666667h16.6666667v7.4074074h-19.047619l-5.026455-5.026455v-19.047619ZM77.7777711,38.8888889v7.4074074h-24.0740741v-7.4074074h8.2781918v-9.2592593h-8.2781918v-7.4074074h10.6591442l5.026455,5.026455v11.6402116h8.3884749ZM29.6296296,30.5555556h9.2592593l7.4074074,7.4074074v8.3333333h-7.4074074v-8.3333333h-9.2592593v8.3333333h-7.4074074v-24.0740741h7.4074074v8.3333333ZM46.2962963,30.5555556h-7.4074074v-8.3333333h7.4074074v8.3333333Z"
/>
</svg>
);
}

export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 }: ProviderIconProps) {
const iconProps: Partial<IconProps> = {
size,
Expand All @@ -101,6 +121,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
openai: OpenAI,
"openai-chatgpt": OpenAI,
openrouter: OpenRouter,
kilo: KiloIcon,
groq: Groq,
mistral: Mistral,
gemini: Google,
Expand All @@ -112,6 +133,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
"zai-coding-plan": ZAI,
ollama: OllamaIcon,
"opencode-zen": OpenCodeZenIcon,
"opencode-go": OpenCodeZenIcon,
nvidia: NvidiaIcon,
minimax: Minimax,
"minimax-cn": Minimax,
Expand Down
24 changes: 20 additions & 4 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ const PROVIDERS = [
envVar: "OPENROUTER_API_KEY",
defaultModel: "openrouter/anthropic/claude-sonnet-4",
},
{
id: "kilo",
name: "Kilo Gateway",
description: "OpenAI-compatible multi-provider gateway",
placeholder: "sk-...",
envVar: "KILO_API_KEY",
defaultModel: "kilo/anthropic/claude-sonnet-4.5",
},
{
id: "opencode-zen",
name: "OpenCode Zen",
Expand All @@ -86,6 +94,14 @@ const PROVIDERS = [
envVar: "OPENCODE_ZEN_API_KEY",
defaultModel: "opencode-zen/kimi-k2.5",
},
{
id: "opencode-go",
name: "OpenCode Go",
description: "Lite OpenCode model catalog and limits",
placeholder: "...",
envVar: "OPENCODE_GO_API_KEY",
defaultModel: "opencode-go/kimi-k2.5",
},
{
id: "anthropic",
name: "Anthropic",
Expand Down Expand Up @@ -529,9 +545,9 @@ export function Settings() {
};

return (
<div className="flex h-full">
<div className="flex h-full min-h-0 overflow-hidden">
{/* Sidebar */}
<div className="flex w-52 flex-shrink-0 flex-col border-r border-app-line/50 bg-app-darkBox/20 overflow-y-auto">
<div className="flex min-h-0 w-52 flex-shrink-0 flex-col overflow-y-auto border-r border-app-line/50 bg-app-darkBox/20">
<div className="px-3 pb-1 pt-4">
<span className="text-tiny font-medium uppercase tracking-wider text-ink-faint">
Settings
Expand All @@ -551,13 +567,13 @@ export function Settings() {
</div>

{/* Content */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="flex h-12 items-center border-b border-app-line bg-app-darkBox/50 px-6">
<h1 className="font-plex text-sm font-medium text-ink">
{SECTIONS.find((s) => s.id === activeSection)?.label}
</h1>
</header>
<div className="flex-1 overflow-y-auto">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{activeSection === "providers" ? (
<div className="mx-auto max-w-2xl px-6 py-6">
{/* Section header */}
Expand Down
57 changes: 36 additions & 21 deletions src/api/cron.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,21 +221,21 @@ fn validate_cron_request(request: &CreateCronRequest) -> Result<(), (StatusCode,
));
}

if let Some(start) = request.active_start_hour {
if start > 23 {
return Err((
StatusCode::BAD_REQUEST,
"active_start_hour must be 0-23".into(),
));
}
if let Some(start) = request.active_start_hour
&& start > 23
{
return Err((
StatusCode::BAD_REQUEST,
"active_start_hour must be 0-23".into(),
));
}
if let Some(end) = request.active_end_hour {
if end > 23 {
return Err((
StatusCode::BAD_REQUEST,
"active_end_hour must be 0-23".into(),
));
}
if let Some(end) = request.active_end_hour
&& end > 23
{
return Err((
StatusCode::BAD_REQUEST,
"active_end_hour must be 0-23".into(),
));
}

Ok(())
Expand All @@ -248,24 +248,39 @@ pub(super) async fn create_or_update_cron(
) -> Result<Json<CronActionResponse>, (StatusCode, Json<CronActionResponse>)> {
if let Err((status, message)) = validate_cron_request(&request) {
tracing::warn!(agent_id = %request.agent_id, cron_id = %request.id, %message, "cron validation failed");
return Err((status, Json(CronActionResponse {
success: false,
message,
})));
return Err((
status,
Json(CronActionResponse {
success: false,
message,
}),
));
}

let stores = state.cron_stores.load();
let schedulers = state.cron_schedulers.load();

let cron_err = |status: StatusCode, message: String| {
(status, Json(CronActionResponse { success: false, message }))
(
status,
Json(CronActionResponse {
success: false,
message,
}),
)
};

let store = stores.get(&request.agent_id).ok_or_else(|| {
cron_err(StatusCode::NOT_FOUND, format!("agent '{}' not found", request.agent_id))
cron_err(
StatusCode::NOT_FOUND,
format!("agent '{}' not found", request.agent_id),
)
})?;
let scheduler = schedulers.get(&request.agent_id).ok_or_else(|| {
cron_err(StatusCode::NOT_FOUND, format!("agent '{}' not found", request.agent_id))
cron_err(
StatusCode::NOT_FOUND,
format!("agent '{}' not found", request.agent_id),
)
})?;

let active_hours = match (request.active_start_hour, request.active_end_hour) {
Expand Down
Loading