diff --git a/docs/cli/index.md b/docs/cli/index.md index 7bc7b559d..1b84e34ca 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -297,7 +297,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -307,6 +307,7 @@ Options: - `--openrouter-api-key ` - `--ai-gateway-api-key ` - `--moonshot-api-key ` +- `--chutes-api-key ` - `--kimi-code-api-key ` - `--gemini-api-key ` - `--zai-api-key ` diff --git a/docs/providers/chutes.md b/docs/providers/chutes.md new file mode 100644 index 000000000..43fb3cb90 --- /dev/null +++ b/docs/providers/chutes.md @@ -0,0 +1,182 @@ +--- +summary: "Use Chutes AI with OpenClaw" +read_when: + - You want to use Chutes AI models in OpenClaw + - You need to configure Chutes via OAuth or API key +--- +# Chutes AI + +Chutes provides high-performance inference for open-weight models, including GLM 4.7 Flash. OpenClaw supports Chutes via both OAuth and API key authentication. + +Models are fetched dynamically from the Chutes API, ensuring you always have access to the latest models, accurate pricing, and context window limits. + +## Why Chutes in OpenClaw + +- **High Performance**: Optimized inference for top-tier open-weight models. +- **Trusted Execution Environment (TEE)**: Run models in a secure, verifiable enclave for maximum privacy. +- **Dynamic Discovery**: Automatic access to new models as they are released on Chutes. +- **OpenAI-compatible**: Standard `/v1` endpoints for seamless integration. + +## Features + +- **OAuth + API Key**: Multiple ways to authenticate based on your needs. +- **TEE Filtering**: Easily filter for models running in a Trusted Execution Environment. +- **Tool Calling**: Support for function calling on major models like Qwen 3 and DeepSeek V3. +- **Streaming**: ✅ Full streaming support for all models. + +## CLI setup + +To configure Chutes with an API key: + +```bash +openclaw onboard --auth-choice chutes-api-key +``` + +To configure Chutes with OAuth (browser-based): + +```bash +openclaw onboard --auth-choice chutes +``` + +**Non-interactive setup:** + +```bash +openclaw onboard --non-interactive \ + --accept-risk \ + --auth-choice chutes-api-key \ + --chutes-api-key "$CHUTES_API_KEY" +``` + +## Which Model Should I Use? + +| Use Case | Recommended Model | Why | +|----------|-------------------|-----| +| **General chat** | `chutes/zai-org/GLM-4.7-Flash` | Fast, reliable, and the default choice | +| **Best Overall** | `chutes/moonshotai/Kimi-K2.5-TEE` | 1T parameter MoE model; perfect scores in reasoning/ethics benchmarks | +| **TEE Privacy** | `chutes/deepseek-ai/DeepSeek-V3.2-TEE` | Top-tier reasoning in a secure enclave | +| **Complex reasoning** | `chutes/Qwen/Qwen3-235B-A22B-Instruct-2507-TEE` | Massive 235B model with TEE support | +| **Tool calling** | `chutes/chutesai/Mistral-Small-3.1-24B-Instruct-2503` | Excellent tool support and performance | + +OAuth allows you to use your Chutes account without manually managing API keys. OpenClaw uses the standard [Sign in with Chutes](https://github.com/chutesai/Sign-in-with-Chutes) flow. + +### OAuth Scopes + +OpenClaw requests the following scopes by default: +- `openid` (Required for authentication) +- `profile` (Access to username, email, name) +- `chutes:invoke` (Required to make AI API calls on your behalf) + +### Custom OAuth App (Advanced) + +If you wish to use your own OAuth application instead of the default, set these environment variables before running onboarding: + +- `CHUTES_CLIENT_ID`: Your OAuth client ID +- `CHUTES_CLIENT_SECRET`: Your OAuth client secret (if applicable) +- `CHUTES_OAUTH_REDIRECT_URI`: Your redirect URI (default: `http://127.0.0.1:1456/oauth-callback`) + + +## Config snippet + +```json5 +{ + env: { CHUTES_API_KEY: "sk-..." }, + agents: { defaults: { model: { primary: "chutes/zai-org/GLM-4.7-Flash" } } }, + models: { + providers: { + chutes: { + baseUrl: "https://llm.chutes.ai/v1", + api: "openai-completions", + apiKey: "${CHUTES_API_KEY}", + teeOnly: false // Set to true to filter models by Trusted Execution Environment + } + } + } +} +``` + +## Model Discovery + +OpenClaw automatically discovers models from the Chutes API when credentials are configured. If the API is unreachable, it falls back to a curated catalog of popular models. + +The discovery process: +1. Fetches available models from `https://llm.chutes.ai/v1/models` +2. Merges with local catalog metadata (context windows, capabilities) +3. Applies `teeOnly` filtering if configured + +## Available Models + +### TEE Models (Trusted Execution Environment) + +| Model ID | Name | Context | Features | +|----------|------|---------|----------| +| `moonshotai/Kimi-K2.5-TEE` | Kimi K2.5 | 256k | Vision, tools | +| `deepseek-ai/DeepSeek-V3.2-TEE` | DeepSeek V3.2 | 203k | Reasoning, tools | +| `Qwen/Qwen3-235B-A22B-Instruct-2507-TEE` | Qwen 3 235B | 262k | Tools | +| `mistralai/Mistral-Small-24B-Instruct-2501-TEE` | Mistral Small 24B | 131k | Tools | + +### Standard Models + +| Model ID | Name | Context | Features | +|----------|------|---------|----------| +| `zai-org/GLM-4.7-Flash` | GLM 4.7 Flash | 128k | Fast, general | +| `chutesai/Mistral-Small-3.1-24B-Instruct-2503` | Mistral Small 3.1 | 131k | Tools | +| `NousResearch/Hermes-4-14B` | Hermes 4 14B | 41k | Tools | + +For a full list, see the [Chutes Models API](https://llm.chutes.ai/v1/models). + +## Streaming and Tool Support + +| Feature | Support | +|---------|---------| +| **Streaming** | ✅ All models | +| **Function calling** | ✅ Most models (Qwen 3, DeepSeek, Mistral, Kimi) | +| **Vision/Images** | ✅ Kimi K2.5 | +| **JSON mode** | ✅ Supported via `response_format` | + +## Usage Examples + +```bash +# Use default model (GLM 4.7 Flash) +openclaw chat --model chutes/zai-org/GLM-4.7-Flash "Hello!" + +# Use Kimi K2.5 TEE (best overall) +openclaw chat --model chutes/moonshotai/Kimi-K2.5-TEE "Explain quantum computing" + +# Use DeepSeek V3.2 TEE for reasoning +openclaw chat --model chutes/deepseek-ai/DeepSeek-V3.2-TEE "Solve this logic puzzle..." + +# List available Chutes models +openclaw models list | grep chutes +``` + +## Troubleshooting + +### API key not recognized + +```bash +echo $CHUTES_API_KEY +openclaw models list | grep chutes +``` + +Ensure the key is valid and starts with the expected prefix. + +### Model not available + +The Chutes model catalog updates dynamically. Run `openclaw models list` to see currently available models. Some models may be temporarily offline. + +### Connection issues + +Chutes API is at `https://llm.chutes.ai/v1`. Ensure your network allows HTTPS connections. + +## Notes + +- Chutes models use the `chutes/` provider prefix +- Default model: `chutes/zai-org/GLM-4.7-Flash` +- OpenAI-compatible endpoints +- **TEE models** run in a Trusted Execution Environment for maximum privacy; filter with `teeOnly: true` + +## Links + +- [Chutes AI](https://chutes.ai) +- [Models API](https://llm.chutes.ai/v1/models) +- [Sign in with Chutes](https://github.com/chutesai/Sign-in-with-Chutes) diff --git a/docs/providers/index.md b/docs/providers/index.md index b2793ee22..9e0d93098 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -42,6 +42,7 @@ See [Venice AI](/providers/venice). - [OpenCode Zen](/providers/opencode) - [Amazon Bedrock](/bedrock) - [Z.AI](/providers/zai) +- [Chutes AI](/providers/chutes) - [Xiaomi](/providers/xiaomi) - [GLM models](/providers/glm) - [MiniMax](/providers/minimax) diff --git a/src/agents/chutes-models.ts b/src/agents/chutes-models.ts new file mode 100644 index 000000000..aaa4115bc --- /dev/null +++ b/src/agents/chutes-models.ts @@ -0,0 +1,126 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { + CHUTES_BASE_URL, + CHUTES_DEFAULT_COST, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, +} from "../commands/onboard-auth.models.js"; + +export { + CHUTES_BASE_URL, + CHUTES_DEFAULT_COST, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, + CHUTES_MODEL_CATALOG, +}; + +export interface ChutesModelEntry { + id: string; + name?: string; + context_length?: number; + max_output_length?: number; + confidential_compute?: boolean; + pricing?: { prompt: number; completion: number }; + supported_features?: string[]; +} + +export async function fetchChutesModels(): Promise { + // Skip dynamic fetching in test environments to avoid network issues and timeouts. + if ( + process.env.VITEST || + process.env.NODE_ENV === "test" || + process.env.OPENCLAW_SKIP_DYNAMIC_MODELS === "1" + ) { + return []; + } + try { + const response = await fetch(`${CHUTES_BASE_URL}/models`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + throw new Error(`Failed to fetch Chutes models: ${response.statusText}`); + } + const data = (await response.json()) as { data: ChutesModelEntry[] }; + return data.data || []; + } catch (error) { + console.warn(`[chutes-models] Failed to fetch models: ${String(error)}`); + return []; + } +} + +export function mapChutesModelToDefinition(entry: ChutesModelEntry): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name || entry.id, + reasoning: entry.supported_features?.includes("reasoning") ?? false, + input: ["text"], + cost: { + input: entry.pricing?.prompt ?? 0, + output: entry.pricing?.completion ?? 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: entry.context_length || 128000, + maxTokens: entry.max_output_length || 4096, + confidentialCompute: entry.confidential_compute, + }; +} + +/** Convert a catalog entry to a mutable ModelDefinitionConfig */ +function catalogEntryToDefinition( + entry: (typeof CHUTES_MODEL_CATALOG)[number], +): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + input: [...entry.input], // spread to make mutable + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + cost: CHUTES_DEFAULT_COST, + confidentialCompute: "confidentialCompute" in entry ? entry.confidentialCompute : undefined, + }; +} + +export async function discoverChutesModels(opts?: { + teeOnly?: boolean; +}): Promise { + const catalogModels = CHUTES_MODEL_CATALOG.map(catalogEntryToDefinition); + + const apiModels = await fetchChutesModels(); + if (apiModels.length === 0) { + if (opts?.teeOnly) { + return catalogModels.filter((m) => m.confidentialCompute === true); + } + return catalogModels; + } + + // Merge discovered models with catalog metadata + const catalogById = new Map( + CHUTES_MODEL_CATALOG.map((m) => [m.id, m]), + ); + const models: ModelDefinitionConfig[] = []; + + for (const apiModel of apiModels) { + const catalogEntry = catalogById.get(apiModel.id); + if (catalogEntry) { + // Use catalog metadata for known models, but respect API's confidential_compute + const def = catalogEntryToDefinition(catalogEntry); + def.confidentialCompute = + apiModel.confidential_compute ?? + ("confidentialCompute" in catalogEntry ? catalogEntry.confidentialCompute : undefined); + models.push(def); + } else { + // Create definition for newly discovered models not in catalog + models.push(mapChutesModelToDefinition(apiModel)); + } + } + + let filtered = models; + if (opts?.teeOnly) { + filtered = models.filter((model) => model.confidentialCompute === true); + } + + return filtered.length > 0 ? filtered : catalogModels; +} diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 4890cb8e6..2d18b0db4 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -14,6 +14,8 @@ export type ChutesPkce = { verifier: string; challenge: string }; export type ChutesUserInfo = { sub?: string; username?: string; + email?: string; + name?: string; created_at?: string; }; @@ -130,7 +132,7 @@ export async function exchangeChutesCodeForTokens(params: { access, refresh, expires: coerceExpiresAt(expiresIn, now), - email: info?.username, + email: info?.email || info?.username, accountId: info?.sub, clientId: params.app.clientId, } as unknown as ChutesStoredOAuth; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1445b53f7..e2b5623b0 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -248,7 +248,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { } if (normalized === "chutes") { - return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY"); + return pick("CHUTES_API_KEY") ?? pick("CHUTES_OAUTH_TOKEN"); } if (normalized === "zai") { diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..915d561e0 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -13,6 +13,7 @@ import { SYNTHETIC_MODEL_CATALOG, } from "./synthetic-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { discoverChutesModels, CHUTES_BASE_URL } from "./chutes-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -379,6 +380,16 @@ async function buildVeniceProvider(): Promise { }; } +async function buildChutesProvider(opts?: { teeOnly?: boolean }): Promise { + const models = await discoverChutesModels(opts); + return { + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + models, + teeOnly: opts?.teeOnly, + }; +} + async function buildOllamaProvider(): Promise { const models = await discoverOllamaModels(); return { @@ -390,6 +401,7 @@ async function buildOllamaProvider(): Promise { export async function resolveImplicitProviders(params: { agentDir: string; + config?: OpenClawConfig; }): Promise { const providers: Record = {}; const authStore = ensureAuthProfileStore(params.agentDir, { @@ -431,6 +443,15 @@ export async function resolveImplicitProviders(params: { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } + const chutesKey = + resolveEnvApiKeyVarName("chutes") ?? + resolveApiKeyFromProfiles({ provider: "chutes", store: authStore }); + if (chutesKey) { + const chutesCfg = params.config?.models?.providers?.chutes; + const teeOnly = chutesCfg?.teeOnly === true; + providers.chutes = { ...(await buildChutesProvider({ teeOnly })), apiKey: chutesKey }; + } + const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); if (qwenProfiles.length > 0) { providers["qwen-portal"] = { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 22c21af94..1752c8ff8 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -80,7 +80,7 @@ export async function ensureOpenClawModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); const explicitProviders = (cfg.models?.providers ?? {}) as Record; - const implicitProviders = await resolveImplicitProviders({ agentDir }); + const implicitProviders = await resolveImplicitProviders({ agentDir, config: cfg }); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 3f81a5ee8..253e09487 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|chutes-api-key|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -69,6 +69,7 @@ export function registerOnboardCommand(program: Command) { .option("--openrouter-api-key ", "OpenRouter API key") .option("--ai-gateway-api-key ", "Vercel AI Gateway API key") .option("--moonshot-api-key ", "Moonshot API key") + .option("--chutes-api-key ", "Chutes API key") .option("--kimi-code-api-key ", "Kimi Code API key") .option("--gemini-api-key ", "Gemini API key") .option("--zai-api-key ", "Z.AI API key") @@ -120,6 +121,7 @@ export function registerOnboardCommand(program: Command) { openrouterApiKey: opts.openrouterApiKey as string | undefined, aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined, moonshotApiKey: opts.moonshotApiKey as string | undefined, + chutesApiKey: opts.chutesApiKey as string | undefined, kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined, zaiApiKey: opts.zaiApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index c85cc0b4d..2835813f4 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -85,7 +85,7 @@ describe("buildAuthChoiceOptions", () => { expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true); }); - it("includes Chutes OAuth auth choice", () => { + it("includes Chutes OAuth and API key auth choices", () => { const store: AuthProfileStore = { version: 1, profiles: {} }; const options = buildAuthChoiceOptions({ store, @@ -93,6 +93,7 @@ describe("buildAuthChoiceOptions", () => { }); expect(options.some((opt) => opt.value === "chutes")).toBe(true); + expect(options.some((opt) => opt.value === "chutes-api-key")).toBe(true); }); it("includes Qwen auth choice", () => { diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 5acddf4e3..b761174fe 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -21,6 +21,7 @@ export type AuthChoiceGroupId = | "minimax" | "synthetic" | "venice" + | "chutes" | "qwen"; export type AuthChoiceGroup = { @@ -54,6 +55,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "M2.1 (recommended)", choices: ["minimax-api", "minimax-api-lightning"], }, + { + value: "chutes", + label: "Chutes AI", + hint: "TEE privacy + high-performance open models", + choices: ["chutes", "chutes-api-key"], + }, { value: "qwen", label: "Qwen", @@ -139,7 +146,16 @@ export function buildAuthChoiceOptions(params: { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)", }); - options.push({ value: "chutes", label: "Chutes (OAuth)" }); + options.push({ + value: "chutes", + label: "Chutes (OAuth)", + hint: "TEE privacy + high-performance inference", + }); + options.push({ + value: "chutes-api-key", + label: "Chutes API key", + hint: "TEE privacy + high-performance inference", + }); options.push({ value: "openai-api-key", label: "OpenAI API key" }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index fa4fc77e7..924568d28 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -13,6 +13,8 @@ import { } from "./google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyChutesConfig, + applyChutesProviderConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, applyMoonshotConfig, @@ -30,12 +32,14 @@ import { applyXiaomiConfig, applyXiaomiProviderConfig, applyZaiConfig, + CHUTES_DEFAULT_MODEL_REF, KIMI_CODE_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + setChutesApiKey, XIAOMI_DEFAULT_MODEL_REF, setGeminiApiKey, setKimiCodeApiKey, @@ -77,6 +81,8 @@ export async function applyAuthChoiceApiProviders( authChoice = "ai-gateway-api-key"; } else if (params.opts.tokenProvider === "moonshot") { authChoice = "moonshot-api-key"; + } else if (params.opts.tokenProvider === "chutes") { + authChoice = "chutes-api-key"; } else if (params.opts.tokenProvider === "kimi-code") { authChoice = "kimi-code-api-key"; } else if (params.opts.tokenProvider === "google") { @@ -271,6 +277,53 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "chutes-api-key") { + let hasCredential = false; + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "chutes") { + await setChutesApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + const envKey = process.env.CHUTES_API_KEY?.trim(); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing CHUTES_API_KEY (env, ${formatApiKeyPreview(envKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setChutesApiKey(envKey, params.agentDir); + hasCredential = true; + } + } + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Chutes API key", + validate: validateApiKeyInput, + }); + await setChutesApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "chutes:default", + provider: "chutes", + mode: "api_key", + }); + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: CHUTES_DEFAULT_MODEL_REF, + applyDefaultConfig: applyChutesConfig, + applyProviderConfig: applyChutesProviderConfig, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "kimi-code-api-key") { let hasCredential = false; if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "kimi-code") { diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a4d831c92..40cedcc81 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -13,6 +13,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "openrouter-api-key": "openrouter", "ai-gateway-api-key": "vercel-ai-gateway", "moonshot-api-key": "moonshot", + "chutes-api-key": "chutes", "kimi-code-api-key": "kimi-code", "gemini-api-key": "google", "google-antigravity": "google-antigravity", diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index cadf962b1..754a188dc 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import type { ProviderPlugin } from "../plugins/types.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import type { AuthChoice } from "./onboard-types.js"; @@ -13,7 +14,7 @@ vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); -const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [] as ProviderPlugin[])); vi.mock("../plugins/providers.js", () => ({ resolvePluginProviders, })); @@ -492,6 +493,117 @@ describe("applyAuthChoice", () => { }); }); + it("prompts and writes Chutes API key when selecting chutes-api-key", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + + const text = vi.fn().mockResolvedValue("sk-chutes-test"); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "chutes-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(text).toHaveBeenCalledWith(expect.objectContaining({ message: "Enter Chutes API key" })); + expect(result.config.auth?.profiles?.["chutes:default"]).toMatchObject({ + provider: "chutes", + mode: "api_key", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["chutes:default"]?.key).toBe("sk-chutes-test"); + }); + + it("uses existing CHUTES_API_KEY when selecting chutes-api-key", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR; + process.env.CHUTES_API_KEY = "sk-chutes-env-test"; + + const text = vi.fn(); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const confirm = vi.fn(async () => true); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "chutes-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("CHUTES_API_KEY"), + }), + ); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["chutes:default"]).toMatchObject({ + provider: "chutes", + mode: "api_key", + }); + + const authProfilePath = authProfilePathFor(requireAgentDir()); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["chutes:default"]?.key).toBe("sk-chutes-env-test"); + + delete process.env.CHUTES_API_KEY; + }); + it("writes Qwen credentials when selecting qwen-portal", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; @@ -512,7 +624,7 @@ describe("applyAuthChoice", () => { { profileId: "qwen-portal:default", credential: { - type: "oauth", + type: "oauth" as const, provider: "qwen-portal", access: "access", refresh: "refresh", @@ -526,7 +638,7 @@ describe("applyAuthChoice", () => { "qwen-portal": { baseUrl: "https://portal.qwen.ai/v1", apiKey: "qwen-oauth", - api: "openai-completions", + api: "openai-completions" as const, models: [], }, }, diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index c94eeb51b..95f3a4a9c 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -19,8 +19,11 @@ import { ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; import { + buildChutesModelDefinition, buildKimiCodeModelDefinition, buildMoonshotModelDefinition, + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_REF, KIMI_CODE_BASE_URL, KIMI_CODE_MODEL_ID, KIMI_CODE_MODEL_REF, @@ -204,6 +207,67 @@ export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig { }; } +export function applyChutesProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[CHUTES_DEFAULT_MODEL_REF] = { + ...models[CHUTES_DEFAULT_MODEL_REF], + alias: models[CHUTES_DEFAULT_MODEL_REF]?.alias ?? "GLM 4.7 Flash", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.chutes; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string; teeOnly?: boolean }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + providers.chutes = { + ...existingProviderRest, + baseUrl: CHUTES_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: existingProvider?.models || [buildChutesModelDefinition()], + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +export function applyChutesConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyChutesProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: CHUTES_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[KIMI_CODE_MODEL_REF] = { diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index fbf6dbfb9..b2f9551cd 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -73,6 +73,19 @@ export async function setMoonshotApiKey(key: string, agentDir?: string) { }); } +export async function setChutesApiKey(key: string, agentDir?: string) { + // Write to resolved agent dir so gateway finds credentials on startup. + upsertAuthProfile({ + profileId: "chutes:default", + credential: { + type: "api_key", + provider: "chutes", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setKimiCodeApiKey(key: string, agentDir?: string) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index de5a4edaa..5ced0e908 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -12,6 +12,11 @@ export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; +export const CHUTES_BASE_URL = "https://llm.chutes.ai/v1"; +export const CHUTES_DEFAULT_MODEL_ID = "zai-org/GLM-4.7-Flash"; +export const CHUTES_DEFAULT_MODEL_REF = `chutes/${CHUTES_DEFAULT_MODEL_ID}`; +export const CHUTES_DEFAULT_CONTEXT_WINDOW = 128000; +export const CHUTES_DEFAULT_MAX_TOKENS = 4096; export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; export const KIMI_CODE_MODEL_ID = "kimi-for-coding"; export const KIMI_CODE_MODEL_REF = `kimi-code/${KIMI_CODE_MODEL_ID}`; @@ -45,6 +50,12 @@ export const MOONSHOT_DEFAULT_COST = { cacheRead: 0, cacheWrite: 0, }; +export const CHUTES_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; export const KIMI_CODE_DEFAULT_COST = { input: 0, output: 0, @@ -91,6 +102,64 @@ export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinition }); } +/** + * Complete catalog of popular Chutes AI models. + * This catalog serves as a fallback when the Chutes API is unreachable. + */ +export const CHUTES_MODEL_CATALOG = [ + { + id: "zai-org/GLM-4.7-Flash", + name: "GLM 4.7 Flash", + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 4096, + }, + { + id: "moonshotai/Kimi-K2.5-TEE", + name: "Kimi K2.5 (TEE)", + reasoning: false, + input: ["text", "image"], + contextWindow: 256000, + maxTokens: 8192, + confidentialCompute: true, + }, + { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507-TEE", + name: "Qwen 3 235B (Tools, TEE)", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 4096, + confidentialCompute: true, + }, + { + id: "deepseek-ai/DeepSeek-V3.2-TEE", + name: "DeepSeek V3.2 (Tools, TEE)", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 4096, + confidentialCompute: true, + }, + { + id: "chutesai/Mistral-Small-3.1-24B-Instruct-2503", + name: "Mistral Small 3.1 (Tools)", + reasoning: false, + input: ["text"], + contextWindow: 131072, + maxTokens: 4096, + }, + { + id: "NousResearch/Hermes-4-14B", + name: "Hermes 4 14B (Tools)", + reasoning: false, + input: ["text"], + contextWindow: 40960, + maxTokens: 4096, + }, +] as const; + export function buildMoonshotModelDefinition(): ModelDefinitionConfig { return { id: MOONSHOT_DEFAULT_MODEL_ID, @@ -103,6 +172,29 @@ export function buildMoonshotModelDefinition(): ModelDefinitionConfig { }; } +export function buildChutesModelDefinition( + modelId: string = CHUTES_DEFAULT_MODEL_ID, +): ModelDefinitionConfig { + const catalogEntry = CHUTES_MODEL_CATALOG.find((m) => m.id === modelId); + if (catalogEntry) { + return { + ...catalogEntry, + input: [...catalogEntry.input], + cost: CHUTES_DEFAULT_COST, + }; + } + + return { + id: modelId, + name: modelId === CHUTES_DEFAULT_MODEL_ID ? "GLM 4.7 Flash" : modelId, + reasoning: false, + input: ["text"], + cost: CHUTES_DEFAULT_COST, + contextWindow: CHUTES_DEFAULT_CONTEXT_WINDOW, + maxTokens: CHUTES_DEFAULT_MAX_TOKENS, + }; +} + export function buildKimiCodeModelDefinition(): ModelDefinitionConfig { return { id: KIMI_CODE_MODEL_ID, diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 612b24865..94519dd01 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -5,6 +5,8 @@ export { export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; export { applyAuthProfileConfig, + applyChutesConfig, + applyChutesProviderConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, applyMoonshotConfig, @@ -37,6 +39,7 @@ export { export { OPENROUTER_DEFAULT_MODEL_REF, setAnthropicApiKey, + setChutesApiKey, setGeminiApiKey, setKimiCodeApiKey, setMinimaxApiKey, @@ -54,10 +57,14 @@ export { ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { + buildChutesModelDefinition, buildKimiCodeModelDefinition, buildMinimaxApiModelDefinition, buildMinimaxModelDefinition, buildMoonshotModelDefinition, + CHUTES_BASE_URL, + CHUTES_DEFAULT_MODEL_ID, + CHUTES_DEFAULT_MODEL_REF, DEFAULT_MINIMAX_BASE_URL, KIMI_CODE_BASE_URL, KIMI_CODE_MODEL_ID, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 8719a1f1a..94839e239 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -8,6 +8,7 @@ import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-tok import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyChutesConfig, applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxConfig, @@ -20,6 +21,7 @@ import { applyXiaomiConfig, applyZaiConfig, setAnthropicApiKey, + setChutesApiKey, setGeminiApiKey, setKimiCodeApiKey, setMinimaxApiKey, @@ -33,7 +35,7 @@ import { setZaiApiKey, } from "../../onboard-auth.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; -import { resolveNonInteractiveApiKey } from "../api-keys.js"; +import { NonInteractiveApiKeySource, resolveNonInteractiveApiKey } from "../api-keys.js"; import { shortenHomePath } from "../../../utils.js"; export async function applyNonInteractiveAuthChoice(params: { @@ -273,6 +275,45 @@ export async function applyNonInteractiveAuthChoice(params: { return applyMoonshotConfig(nextConfig); } + if (authChoice === "chutes-api-key") { + let resolvedKey = opts.chutesApiKey?.trim(); + let source: NonInteractiveApiKeySource = "flag"; + + if (!resolvedKey) { + resolvedKey = process.env.CHUTES_API_KEY?.trim(); + source = "env"; + } + + if (!resolvedKey) { + const resolved = await resolveNonInteractiveApiKey({ + provider: "chutes", + cfg: baseConfig, + flagValue: opts.chutesApiKey, + flagName: "--chutes-api-key", + envVar: "CHUTES_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source === "env" && !process.env.CHUTES_API_KEY) { + // resolveNonInteractiveApiKey found CHUTES_OAUTH_TOKEN via resolveEnvApiKey + // Skip it for api-key onboarding. + runtime.error("Missing --chutes-api-key (or CHUTES_API_KEY in env)."); + runtime.exit(1); + return null; + } + resolvedKey = resolved.key; + source = resolved.source; + } + + if (source !== "profile") await setChutesApiKey(resolvedKey); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "chutes:default", + provider: "chutes", + mode: "api_key", + }); + return applyChutesConfig(nextConfig); + } + if (authChoice === "kimi-code-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "kimi-code", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f4154bc6d..6cd156e17 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -9,6 +9,7 @@ export type AuthChoice = | "claude-cli" | "token" | "chutes" + | "chutes-api-key" | "openai-codex" | "openai-api-key" | "openrouter-api-key" @@ -65,6 +66,7 @@ export type OnboardOptions = { openrouterApiKey?: string; aiGatewayApiKey?: string; moonshotApiKey?: string; + chutesApiKey?: string; kimiCodeApiKey?: string; geminiApiKey?: string; zaiApiKey?: string; diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..2c8ee03db 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -31,6 +31,8 @@ export type ModelDefinitionConfig = { maxTokens: number; headers?: Record; compat?: ModelCompatConfig; + /** Chutes-only: indicates the model runs in a Trusted Execution Environment */ + confidentialCompute?: boolean; }; export type ModelProviderConfig = { @@ -41,6 +43,8 @@ export type ModelProviderConfig = { headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; + /** Chutes-only: filter models by confidential_compute: true */ + teeOnly?: boolean; }; export type BedrockDiscoveryConfig = { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..2b21af65b 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -43,6 +43,7 @@ export const ModelDefinitionSchema = z maxTokens: z.number().positive().optional(), headers: z.record(z.string(), z.string()).optional(), compat: ModelCompatSchema, + confidentialCompute: z.boolean().optional(), }) .strict(); @@ -57,6 +58,7 @@ export const ModelProviderSchema = z headers: z.record(z.string(), z.string()).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), + teeOnly: z.boolean().optional(), }) .strict();