feat!: redesign model config + auth profiles

This commit is contained in:
Peter Steinberger 2026-01-06 00:56:29 +00:00
parent bd2e003171
commit b04c838c15
60 changed files with 2037 additions and 790 deletions

View File

@ -6,6 +6,7 @@
### Breaking ### Breaking
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
### Fixes ### Fixes
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
@ -16,10 +17,10 @@
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema. - macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json. - Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth-profiles.json.
- Model: `/model` list shows auth source (masked key or OAuth email) per provider. - Model: `/model` list shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`. - Model: `/model list` is an alias for `/model`.
- Model: `/model` output now includes auth source location (env/auth.json/models.json). - Model: `/model` output now includes auth source location (env/auth-profiles.json/models.json).
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output. - Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.

View File

@ -91,18 +91,40 @@ Env var equivalent:
### Auth storage (OAuth + API keys) ### Auth storage (OAuth + API keys)
Clawdbot stores **OAuth credentials** in: Clawdbot stores **auth profiles** (OAuth + API keys) in:
- `~/.clawdbot/agent/auth-profiles.json`
Legacy OAuth imports:
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`) - `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
Clawdbot stores **API keys** in the agent auth store: The embedded Pi agent maintains a runtime cache at:
- `~/.clawdbot/agent/auth.json` - `~/.clawdbot/agent/auth.json` (managed automatically; dont edit manually)
Overrides: Overrides:
- OAuth dir: `CLAWDBOT_OAUTH_DIR` - OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy) - Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
On first use, Clawdbot imports `oauth.json` entries into `auth.json` so the embedded On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
agent can use them. `oauth.json` remains the source of truth for OAuth refresh.
### `auth`
Optional metadata for auth profiles. This does **not** store secrets; it maps
profile IDs to a provider + mode (and optional email) and defines the provider
rotation order used for failover.
```json5
{
auth: {
profiles: {
"anthropic:default": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:work": { provider: "anthropic", mode: "api_key" }
},
order: {
anthropic: ["anthropic:default", "anthropic:work"]
}
}
}
```
### `identity` ### `identity`
@ -494,14 +516,12 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V
### `agent` ### `agent`
Controls the embedded agent runtime (model/thinking/verbose/timeouts). Controls the embedded agent runtime (model/thinking/verbose/timeouts).
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist `agent.models` defines the configured model catalog (and acts as the allowlist for `/model`).
(omit to show the full catalog). `agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers.
`modelAliases` adds short names for `/model` (alias -> provider/model). `agent.imageModel` is optional and is **only used if the primary model lacks image input**.
`modelFallbacks` lists ordered fallback models to try when the default fails.
`imageModel` selects an image-capable model for the `image` tool.
`imageModelFallbacks` lists ordered fallback image models for the `image` tool.
Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists): Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
is already present in `agent.models`:
- `opus` -> `anthropic/claude-opus-4-5` - `opus` -> `anthropic/claude-opus-4-5`
- `sonnet` -> `anthropic/claude-sonnet-4-5` - `sonnet` -> `anthropic/claude-sonnet-4-5`
@ -515,23 +535,24 @@ If you configure the same alias name (case-insensitive) yourself, your value win
```json5 ```json5
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", models: {
allowedModels: [ "anthropic/claude-opus-4-5": { alias: "Opus" },
"anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
"anthropic/claude-sonnet-4-1" "openrouter/deepseek/deepseek-r1:free": {}
], },
modelAliases: { model: {
Opus: "anthropic/claude-opus-4-5", primary: "anthropic/claude-opus-4-5",
Sonnet: "anthropic/claude-sonnet-4-1" fallbacks: [
"openrouter/deepseek/deepseek-r1:free",
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
]
},
imageModel: {
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
fallbacks: [
"openrouter/google/gemini-2.0-flash-vision:free"
]
}, },
modelFallbacks: [
"openrouter/deepseek/deepseek-r1:free",
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
],
imageModel: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
imageModelFallbacks: [
"openrouter/google/gemini-2.0-flash-vision:free"
],
thinkingDefault: "low", thinkingDefault: "low",
verboseDefault: "off", verboseDefault: "off",
elevatedDefault: "on", elevatedDefault: "on",
@ -566,8 +587,8 @@ Block streaming:
} }
``` ```
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). `agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`). Aliases come from `agent.models.*.alias` (e.g. `Opus`).
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
deprecation fallback. deprecation fallback.
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
@ -729,11 +750,16 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into
- default behavior: **merge** (keeps existing providers, overrides on name) - default behavior: **merge** (keeps existing providers, overrides on name)
- set `models.mode: "replace"` to overwrite the file contents - set `models.mode: "replace"` to overwrite the file contents
Select the model via `agent.model` (provider/model). Select the model via `agent.model.primary` (provider/model).
```json5 ```json5
{ {
agent: { model: "custom-proxy/llama-3.1-8b" }, agent: {
model: { primary: "custom-proxy/llama-3.1-8b" },
models: {
"custom-proxy/llama-3.1-8b": {}
}
},
models: { models: {
mode: "merge", mode: "merge",
providers: { providers: {
@ -766,14 +792,10 @@ via **LM Studio** using the **Responses API**.
```json5 ```json5
{ {
agent: { agent: {
model: "Minimax", model: { primary: "lmstudio/minimax-m2.1-gs32" },
allowedModels: [ models: {
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5": { alias: "Opus" },
"lmstudio/minimax-m2.1-gs32" "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
],
modelAliases: {
Opus: "anthropic/claude-opus-4-5",
Minimax: "lmstudio/minimax-m2.1-gs32"
} }
}, },
models: { models: {

View File

@ -27,8 +27,13 @@ Doctor will:
- Show the migration it applied. - Show the migration it applied.
- Rewrite `~/.clawdbot/clawdbot.json` with the updated schema. - Rewrite `~/.clawdbot/clawdbot.json` with the updated schema.
The Gateway also auto-runs doctor migrations on startup when it detects a legacy
config format, so stale configs are repaired without manual intervention.
Current migrations: Current migrations:
- `routing.allowFrom``whatsapp.allowFrom` - `routing.allowFrom``whatsapp.allowFrom`
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
`agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks`
## Usage ## Usage

View File

@ -15,7 +15,8 @@ Everything lives under `~/.clawdbot/`:
|------|---------| |------|---------|
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) | | `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) | | `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) |
| `~/.clawdbot/agent/auth.json` | API key store | | `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) |
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens | | `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
| `~/.clawdbot/sessions/` | Conversation history & state | | `~/.clawdbot/sessions/` | Conversation history & state |
| `~/.clawdbot/sessions/sessions.json` | Session metadata | | `~/.clawdbot/sessions/sessions.json` | Session metadata |
@ -576,21 +577,16 @@ List available models with `/model`, `/model list`, or `/model status`.
Clawdbot ships a few default model shorthands (you can override them in config): Clawdbot ships a few default model shorthands (you can override them in config):
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`. `opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
**Setup:** Configure allowed models and aliases in `clawdbot.json`: **Setup:** Configure models and aliases in `clawdbot.json`:
```json ```json
{ {
"agent": { "agent": {
"model": "anthropic/claude-opus-4-5", "model": { "primary": "anthropic/claude-opus-4-5" },
"allowedModels": [ "models": {
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5": { "alias": "opus" },
"anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5": { "alias": "sonnet" },
"anthropic/claude-haiku-4-5" "anthropic/claude-haiku-4-5": { "alias": "haiku" }
],
"modelAliases": {
"opus": "anthropic/claude-opus-4-5",
"sonnet": "anthropic/claude-sonnet-4-5",
"haiku": "anthropic/claude-haiku-4-5"
} }
} }
} }
@ -606,7 +602,8 @@ If you don't want to use Anthropic directly, you can use alternative providers:
```json5 ```json5
{ {
agent: { agent: {
model: "openrouter/anthropic/claude-sonnet-4", model: { primary: "openrouter/anthropic/claude-sonnet-4" },
models: { "openrouter/anthropic/claude-sonnet-4": {} },
env: { OPENROUTER_API_KEY: "sk-or-..." } env: { OPENROUTER_API_KEY: "sk-or-..." }
} }
} }
@ -616,7 +613,8 @@ If you don't want to use Anthropic directly, you can use alternative providers:
```json5 ```json5
{ {
agent: { agent: {
model: "zai/glm-4.7", model: { primary: "zai/glm-4.7" },
models: { "zai/glm-4.7": {} },
env: { ZAI_API_KEY: "..." } env: { ZAI_API_KEY: "..." }
} }
} }

View File

@ -16,35 +16,32 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks.
- default: configured models only - default: configured models only
- flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain` - flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain`
- `clawdbot models status` - `clawdbot models status`
- show default model + aliases + fallbacks + allowlist - show default model + aliases + fallbacks + configured models
- `clawdbot models set <modelOrAlias>` - `clawdbot models set <modelOrAlias>`
- writes `agent.model` in config - writes `agent.model.primary` and ensures `agent.models` entry
- `clawdbot models set-image <modelOrAlias>` - `clawdbot models set-image <modelOrAlias>`
- writes `agent.imageModel` in config - writes `agent.imageModel.primary` and ensures `agent.models` entry
- `clawdbot models aliases list|add|remove` - `clawdbot models aliases list|add|remove`
- writes `agent.modelAliases` - writes `agent.models.*.alias`
- `clawdbot models fallbacks list|add|remove|clear` - `clawdbot models fallbacks list|add|remove|clear`
- writes `agent.modelFallbacks` - writes `agent.model.fallbacks`
- `clawdbot models image-fallbacks list|add|remove|clear` - `clawdbot models image-fallbacks list|add|remove|clear`
- writes `agent.imageModelFallbacks` - writes `agent.imageModel.fallbacks`
- `clawdbot models scan` - `clawdbot models scan`
- OpenRouter :free scan; probe tool-call + image; interactive selection - OpenRouter :free scan; probe tool-call + image; interactive selection
## Config changes ## Config changes
- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs). - `agent.models` (configured model catalog + aliases).
- Add `agent.imageModel?: string` (optional image-capable model for image tool). - `agent.model.primary` + `agent.model.fallbacks`.
- Add `agent.imageModelFallbacks?: string[]` (ordered list for image tool). - `agent.imageModel.primary` + `agent.imageModel.fallbacks` (optional).
- Keep existing: - `auth.profiles` + `auth.order` for per-provider auth failover.
- `agent.model` (default)
- `agent.allowedModels` (list filter)
- `agent.modelAliases` (shortcut names)
## Scan behavior (models scan) ## Scan behavior (models scan)
Input Input
- OpenRouter `/models` list (filter `:free`) - OpenRouter `/models` list (filter `:free`)
- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage) - Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY`
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates` - Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
- Probe controls: `--timeout`, `--concurrency` - Probe controls: `--timeout`, `--concurrency`
@ -66,17 +63,20 @@ Interactive selection (TTY)
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply. - Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
Output Output
- Writes `agent.modelFallbacks` ordered. - Writes `agent.model.fallbacks` ordered.
- Writes `agent.imageModelFallbacks` ordered (image-capable models). - Writes `agent.imageModel.fallbacks` ordered (image-capable models).
- Optional `--set-default` to set `agent.model`. - Ensures `agent.models` entries exist for selected models.
- Optional `--set-image` to set `agent.imageModel`. - Optional `--set-default` to set `agent.model.primary`.
- Optional `--set-image` to set `agent.imageModel.primary`.
## Runtime fallback ## Runtime fallback
- On model failure: try `agent.modelFallbacks` in order. - On model failure: try `agent.model.fallbacks` in order.
- Ignore fallback entries not in `agent.allowedModels` (if allowlist set). - Per-provider auth failover uses `auth.order` (or stored profile order) **before**
- Persist last successful provider/model to session entry. moving to the next model.
- `/status` shows last used model (not just default). - Image routing uses `agent.imageModel` **only when configured** and the primary
model lacks image input.
- Persist last successful provider/model to session entry; auth profile success is global.
## Tests ## Tests
@ -86,5 +86,5 @@ Output
## Docs ## Docs
- Update `docs/configuration.md` with `agent.modelFallbacks`. - Update `docs/configuration.md` with `agent.models` + `agent.model` + `agent.imageModel`.
- Keep this doc current when CLI surface or scan logic changes. - Keep this doc current when CLI surface or scan logic changes.

View File

@ -41,7 +41,7 @@ The macOS app should:
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`) - `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: its the Clawdbot-owned OAuth store. Why this location matters: its the Clawdbot-owned OAuth store.
Clawdbot also imports `oauth.json` into the agent auth store (`~/.clawdbot/agent/auth.json`) on first use. Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use.
### Recommended: OAuth (OpenAI Codex) ### Recommended: OAuth (OpenAI Codex)
@ -148,7 +148,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored
For now, remote onboarding should: For now, remote onboarding should:
- explain why OAuth isn't shown - explain why OAuth isn't shown
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host - point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files) - mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
### Manual credential setup ### Manual credential setup

View File

@ -87,7 +87,7 @@ Model listing
- alias - alias
- provider - provider
- auth order (from `auth.order`) - auth order (from `auth.order`)
- auth source for the current provider (env/auth.json/models.json) - auth source for the current provider (auth-profiles.json/env/shell env/models.json)
## Fallback behavior (global) ## Fallback behavior (global)
@ -121,19 +121,20 @@ Support detection
## Migration (doctor + gateway auto-run) ## Migration (doctor + gateway auto-run)
Inputs Inputs
- `agent.model` (string) - Legacy keys (pre-migration):
- `agent.modelFallbacks` (string[]) - `agent.model` (string)
- `agent.imageModel` (string) - `agent.modelFallbacks` (string[])
- `agent.imageModelFallbacks` (string[]) - `agent.imageModel` (string)
- `agent.allowedModels` (string[]) - `agent.imageModelFallbacks` (string[])
- `agent.modelAliases` (record) - `agent.allowedModels` (string[])
- `agent.modelAliases` (record)
Outputs Outputs
- `agent.models` map with keys for all referenced models - `agent.models` map with keys for all referenced models
- `agent.model.primary/fallbacks` - `agent.model.primary/fallbacks`
- `agent.imageModel.primary/fallbacks` - `agent.imageModel.primary/fallbacks`
- `auth.profiles` seeded from current auth.json + env (as `provider:default`) - Auth profile store seeded from current auth-profiles.json/auth.json + oauth.json + env (as `provider:default`)
- `auth.order` seeded with `["provider:default"]` - `auth.order` seeded with `["provider:default"]` when config is updated
Auto-run Auto-run
- Gateway start detects legacy keys and runs doctor migration. - Gateway start detects legacy keys and runs doctor migration.

View File

@ -126,7 +126,7 @@ Core parameters:
- `maxBytesMb` (optional size cap) - `maxBytesMb` (optional size cap)
Notes: Notes:
- Only available when `agent.imageModel` or `agent.imageModelFallbacks` is set. - Only available when `agent.imageModel` is configured (primary or fallbacks).
- Uses the image model directly (independent of the main chat model). - Uses the image model directly (independent of the main chat model).
### `cron` ### `cron`

View File

@ -48,7 +48,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/help` - `/help`
- `/status` - `/status`
- `/session <key>` (or `/sessions`) - `/session <key>` (or `/sessions`)
- `/model <provider/model>` (or `/models`) - `/model <provider/model>` (or `/model list`, `/models`)
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>` - `/verbose <on|off>`
- `/elevated <on|off>` - `/elevated <on|off>`

View File

@ -52,7 +52,7 @@ It does **not** install or change anything on the remote host.
- **API key**: stores the key for you. - **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is autowritten for the LM Studio endpoint. - **Minimax M2.1 (LM Studio)**: config is autowritten for the LM Studio endpoint.
- **Skip**: no auth configured yet. - **Skip**: no auth configured yet.
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; API keys live in `~/.clawdbot/agent/auth.json`. - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth).
3) **Workspace** 3) **Workspace**
- Default `~/clawd` (configurable). - Default `~/clawd` (configurable).

View File

@ -1,14 +1,13 @@
import path from "node:path"; import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js";
const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent");
export function resolveClawdbotAgentDir(): string { export function resolveClawdbotAgentDir(): string {
const defaultAgentDir = path.join(resolveConfigDir(), "agent");
const override = const override =
process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.CLAWDBOT_AGENT_DIR?.trim() ||
process.env.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim() ||
DEFAULT_AGENT_DIR; defaultAgentDir;
return resolveUserPath(override); return resolveUserPath(override);
} }

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
type AuthProfileStore,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
describe("resolveAuthProfileOrder", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-work",
},
},
};
it("prioritizes preferred profiles", () => {
const order = resolveAuthProfileOrder({
store,
provider: "anthropic",
preferredProfile: "anthropic:work",
});
expect(order[0]).toBe("anthropic:work");
expect(order).toContain("anthropic:default");
});
it("prioritizes last-good profile when no preferred override", () => {
const order = resolveAuthProfileOrder({
store: { ...store, lastGood: { anthropic: "anthropic:work" } },
provider: "anthropic",
});
expect(order[0]).toBe("anthropic:work");
});
});

314
src/agents/auth-profiles.ts Normal file
View File

@ -0,0 +1,314 @@
import fs from "node:fs";
import path from "node:path";
import {
getOAuthApiKey,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
const LEGACY_AUTH_FILENAME = "auth.json";
export type ApiKeyCredential = {
type: "api_key";
provider: string;
key: string;
email?: string;
};
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
email?: string;
};
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
export type AuthProfileStore = {
version: number;
profiles: Record<string, AuthProfileCredential>;
lastGood?: Record<string, string>;
};
type LegacyAuthStore = Record<string, AuthProfileCredential>;
function resolveAuthStorePath(): string {
const agentDir = resolveClawdbotAgentDir();
return path.join(agentDir, AUTH_PROFILE_FILENAME);
}
function resolveLegacyAuthStorePath(): string {
const agentDir = resolveClawdbotAgentDir();
return path.join(agentDir, LEGACY_AUTH_FILENAME);
}
function loadJsonFile(pathname: string): unknown {
try {
if (!fs.existsSync(pathname)) return undefined;
const raw = fs.readFileSync(pathname, "utf8");
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function saveJsonFile(pathname: string, data: unknown) {
const dir = path.dirname(pathname);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8");
fs.chmodSync(pathname, 0o600);
}
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if ("profiles" in record) return null;
const entries: LegacyAuthStore = {};
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
} as AuthProfileCredential;
}
return Object.keys(entries).length > 0 ? entries : null;
}
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
if (!record.profiles || typeof record.profiles !== "object") return null;
const profiles = record.profiles as Record<string, unknown>;
const normalized: Record<string, AuthProfileCredential> = {};
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
return {
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
: undefined,
};
}
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath);
if (!oauthRaw || typeof oauthRaw !== "object") return false;
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
let mutated = false;
for (const [provider, creds] of Object.entries(oauthEntries)) {
if (!creds || typeof creds !== "object") continue;
const profileId = `${provider}:default`;
if (store.profiles[profileId]) continue;
store.profiles[profileId] = {
type: "oauth",
provider: provider as OAuthProvider,
...creds,
};
mutated = true;
}
return mutated;
}
export function loadAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) return asStore;
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
if (legacy) {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {},
};
for (const [provider, cred] of Object.entries(legacy)) {
const profileId = `${provider}:default`;
store.profiles[profileId] = {
...cred,
provider: cred.provider ?? (provider as OAuthProvider),
};
}
return store;
}
return { version: AUTH_STORE_VERSION, profiles: {} };
}
export function ensureAuthProfileStore(): AuthProfileStore {
const authPath = resolveAuthStorePath();
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) return asStore;
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
const legacy = coerceLegacyStore(legacyRaw);
const store = legacy
? {
version: AUTH_STORE_VERSION,
profiles: Object.fromEntries(
Object.entries(legacy).map(([provider, cred]) => [
`${provider}:default`,
{ ...cred, provider: cred.provider ?? (provider as OAuthProvider) },
]),
),
}
: { version: AUTH_STORE_VERSION, profiles: {} };
const mergedOAuth = mergeOAuthFileIntoStore(store);
const shouldWrite = legacy !== null || mergedOAuth;
if (shouldWrite) {
saveJsonFile(authPath, store);
}
return store;
}
export function saveAuthProfileStore(store: AuthProfileStore): void {
const authPath = resolveAuthStorePath();
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
lastGood: store.lastGood ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}
export function upsertAuthProfile(params: {
profileId: string;
credential: AuthProfileCredential;
}): void {
const store = ensureAuthProfileStore();
store.profiles[params.profileId] = params.credential;
saveAuthProfileStore(store);
}
export function listProfilesForProvider(
store: AuthProfileStore,
provider: string,
): string[] {
return Object.entries(store.profiles)
.filter(([, cred]) => cred.provider === provider)
.map(([id]) => id);
}
export function resolveAuthProfileOrder(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
provider: string;
preferredProfile?: string;
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const configuredOrder = cfg?.auth?.order?.[provider] ?? [];
const lastGood = store.lastGood?.[provider];
const order =
configuredOrder.length > 0
? configuredOrder
: listProfilesForProvider(store, provider);
const filtered = order.filter((profileId) => {
const cred = store.profiles[profileId];
return cred ? cred.provider === provider : true;
});
const deduped: string[] = [];
for (const entry of filtered) {
if (!deduped.includes(entry)) deduped.push(entry);
}
if (preferredProfile && deduped.includes(preferredProfile)) {
const rest = deduped.filter((entry) => entry !== preferredProfile);
if (lastGood && rest.includes(lastGood)) {
return [
preferredProfile,
lastGood,
...rest.filter((entry) => entry !== lastGood),
];
}
return [preferredProfile, ...rest];
}
if (lastGood && deduped.includes(lastGood)) {
return [lastGood, ...deduped.filter((entry) => entry !== lastGood)];
}
return deduped;
}
export async function resolveApiKeyForProfile(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
const { cfg, store, profileId } = params;
const cred = store.profiles[profileId];
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred,
};
const result = await getOAuthApiKey(cred.provider, oauthCreds);
if (!result) return null;
store.profiles[profileId] = {
...cred,
...result.newCredentials,
type: "oauth",
};
saveAuthProfileStore(store);
return {
apiKey: result.apiKey,
provider: cred.provider,
email: cred.email,
};
}
export function markAuthProfileGood(params: {
store: AuthProfileStore;
provider: string;
profileId: string;
}): void {
const { store, provider, profileId } = params;
const profile = store.profiles[profileId];
if (!profile || profile.provider !== provider) return;
store.lastGood = { ...(store.lastGood ?? {}), [provider]: profileId };
saveAuthProfileStore(store);
}
export function resolveAuthStorePathForDisplay(): string {
const pathname = resolveAuthStorePath();
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
export function resolveAuthProfileDisplayLabel(params: {
cfg?: ClawdbotConfig;
store: AuthProfileStore;
profileId: string;
}): string {
const { cfg, store, profileId } = params;
const profile = store.profiles[profileId];
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
const email = configEmail || profile?.email?.trim();
if (email) return `${profileId} (${email})`;
return profileId;
}

View File

@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const oauthFixture = { const oauthFixture = {
@ -13,12 +12,16 @@ const oauthFixture = {
}; };
describe("getApiKeyForModel", () => { describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth.json", async () => { it("migrates legacy oauth.json into auth-profiles.json", async () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
try { try {
process.env.CLAWDBOT_STATE_DIR = tempDir; process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const oauthDir = path.join(tempDir, "credentials"); const oauthDir = path.join(tempDir, "credentials");
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 }); await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
@ -28,10 +31,6 @@ describe("getApiKeyForModel", () => {
"utf8", "utf8",
); );
const agentDir = path.join(tempDir, "agent");
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
const authStorage = discoverAuthStorage(agentDir);
vi.resetModules(); vi.resetModules();
const { getApiKeyForModel } = await import("./model-auth.js"); const { getApiKeyForModel } = await import("./model-auth.js");
@ -41,18 +40,21 @@ describe("getApiKeyForModel", () => {
api: "openai-codex-responses", api: "openai-codex-responses",
} as Model<Api>; } as Model<Api>;
const apiKey = await getApiKeyForModel(model, authStorage); const apiKey = await getApiKeyForModel({ model });
expect(apiKey).toBe(oauthFixture.access); expect(apiKey.apiKey).toBe(oauthFixture.access);
const authJson = await fs.readFile( const authProfiles = await fs.readFile(
path.join(agentDir, "auth.json"), path.join(tempDir, "agent", "auth-profiles.json"),
"utf8", "utf8",
); );
const authData = JSON.parse(authJson) as Record<string, unknown>; const authData = JSON.parse(authProfiles) as Record<string, unknown>;
expect(authData["openai-codex"]).toMatchObject({ expect(authData.profiles).toMatchObject({
type: "oauth", "openai-codex:default": {
access: oauthFixture.access, type: "oauth",
refresh: oauthFixture.refresh, provider: "openai-codex",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
},
}); });
} finally { } finally {
if (previousStateDir === undefined) { if (previousStateDir === undefined) {
@ -60,6 +62,16 @@ describe("getApiKeyForModel", () => {
} else { } else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir; process.env.CLAWDBOT_STATE_DIR = previousStateDir;
} }
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
} }
}); });

View File

@ -1,179 +1,147 @@
import fsSync from "node:fs"; import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
import os from "node:os"; import type { ClawdbotConfig } from "../config/config.js";
import path from "node:path"; import type { ModelProviderConfig } from "../config/types.js";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { import {
type Api, type AuthProfileStore,
getEnvApiKey, ensureAuthProfileStore,
getOAuthApiKey, resolveApiKeyForProfile,
type Model, resolveAuthProfileOrder,
type OAuthCredentials, } from "./auth-profiles.js";
type OAuthProvider,
} from "@mariozechner/pi-ai";
import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { CONFIG_DIR, resolveUserPath } from "../utils.js"; export {
ensureAuthProfileStore,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
const OAUTH_FILENAME = "oauth.json"; export function getCustomProviderApiKey(
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); cfg: ClawdbotConfig | undefined,
let oauthStorageConfigured = false; provider: string,
let oauthStorageMigrated = false; ): string | undefined {
const providers = cfg?.models?.providers ?? {};
type OAuthStorage = Record<string, OAuthCredentials>; const entry = providers[provider] as ModelProviderConfig | undefined;
const key = entry?.apiKey?.trim();
function resolveClawdbotOAuthPath(): string { return key || undefined;
const overrideDir =
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
} }
function loadOAuthStorageAt(pathname: string): OAuthStorage | null { export async function resolveApiKeyForProvider(params: {
if (!fsSync.existsSync(pathname)) return null; provider: string;
try { cfg?: ClawdbotConfig;
const content = fsSync.readFileSync(pathname, "utf8"); profileId?: string;
const json = JSON.parse(content) as OAuthStorage; preferredProfile?: string;
if (!json || typeof json !== "object") return null; store?: AuthProfileStore;
return json; }): Promise<{ apiKey: string; profileId?: string; source: string }> {
} catch { const { provider, cfg, profileId, preferredProfile } = params;
return null; const store = params.store ?? ensureAuthProfileStore();
}
}
function hasAnthropicOAuth(storage: OAuthStorage): boolean { if (profileId) {
const entry = storage.anthropic as const resolved = await resolveApiKeyForProfile({
| { cfg,
refresh?: string; store,
refresh_token?: string; profileId,
refreshToken?: string; });
access?: string; if (!resolved) {
access_token?: string; throw new Error(`No credentials found for profile "${profileId}".`);
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
}
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
const dir = path.dirname(pathname);
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
fsSync.writeFileSync(
pathname,
`${JSON.stringify(storage, null, 2)}\n`,
"utf8",
);
fsSync.chmodSync(pathname, 0o600);
}
function legacyOAuthPaths(): string[] {
const paths: string[] = [];
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
if (piOverride) {
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
}
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
return Array.from(new Set(paths));
}
function importLegacyOAuthIfNeeded(destPath: string): void {
if (fsSync.existsSync(destPath)) return;
for (const legacyPath of legacyOAuthPaths()) {
const storage = loadOAuthStorageAt(legacyPath);
if (!storage || !hasAnthropicOAuth(storage)) continue;
saveOAuthStorageAt(destPath, storage);
return;
}
}
export function ensureOAuthStorage(): void {
if (oauthStorageConfigured) return;
oauthStorageConfigured = true;
const oauthPath = resolveClawdbotOAuthPath();
importLegacyOAuthIfNeeded(oauthPath);
}
function isValidOAuthCredential(
entry: OAuthCredentials | undefined,
): entry is OAuthCredentials {
if (!entry) return false;
return Boolean(
entry.access?.trim() &&
entry.refresh?.trim() &&
Number.isFinite(entry.expires),
);
}
function migrateOAuthStorageToAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
if (oauthStorageMigrated) return;
oauthStorageMigrated = true;
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (!storage) return;
for (const [provider, creds] of Object.entries(storage)) {
if (!isValidOAuthCredential(creds)) continue;
if (authStorage.get(provider)) continue;
authStorage.set(provider, { type: "oauth", ...creds });
}
}
export function hydrateAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
}
function isOAuthProvider(provider: string): provider is OAuthProvider {
return (
provider === "anthropic" ||
provider === "anthropic-oauth" ||
provider === "google" ||
provider === "openai" ||
provider === "openai-compatible" ||
provider === "openai-codex" ||
provider === "github-copilot" ||
provider === "google-gemini-cli" ||
provider === "google-antigravity"
);
}
export async function getApiKeyForModel(
model: Model<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
const storedKey = await authStorage.getApiKey(model.provider);
if (storedKey) return storedKey;
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
const envKey = getEnvApiKey(model.provider);
if (envKey) return envKey;
if (isOAuthProvider(model.provider)) {
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (storage) {
try {
const result = await getOAuthApiKey(model.provider, storage);
if (result?.apiKey) {
storage[model.provider] = result.newCredentials;
saveOAuthStorageAt(oauthPath, storage);
return result.apiKey;
}
} catch {
// fall through to error below
}
} }
return {
apiKey: resolved.apiKey,
profileId,
source: `profile:${profileId}`,
};
} }
throw new Error(`No API key found for provider "${model.provider}"`);
const order = resolveAuthProfileOrder({
cfg,
store,
provider,
preferredProfile,
});
for (const candidate of order) {
try {
const resolved = await resolveApiKeyForProfile({
cfg,
store,
profileId: candidate,
});
if (resolved) {
return {
apiKey: resolved.apiKey,
profileId: candidate,
source: `profile:${candidate}`,
};
}
} catch {}
}
const envResolved = resolveEnvApiKey(provider);
if (envResolved) {
return { apiKey: envResolved.apiKey, source: envResolved.source };
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return { apiKey: customKey, source: "models.json" };
}
throw new Error(`No API key found for provider "${provider}".`);
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = process.env[envVar]?.trim();
if (!value) return null;
const source = applied.has(envVar)
? `shell env: ${envVar}`
: `env: ${envVar}`;
return { apiKey: value, source };
};
if (provider === "github-copilot") {
return (
pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN")
);
}
if (provider === "anthropic") {
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
}
if (provider === "google-vertex") {
const envKey = getEnvApiKey(provider);
if (!envKey) return null;
return { apiKey: envKey, source: "gcloud adc" };
}
const envMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};
const envVar = envMap[provider];
if (!envVar) return null;
return pick(envVar);
}
export async function getApiKeyForModel(params: {
model: Model<Api>;
cfg?: ClawdbotConfig;
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
return resolveApiKeyForProvider({
provider: params.model.provider,
cfg: params.cfg,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
store: params.store,
});
} }

View File

@ -33,7 +33,10 @@ function buildAllowedModelKeys(
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
defaultProvider: string, defaultProvider: string,
): Set<string> | null { ): Set<string> | null {
const rawAllowlist = cfg?.agent?.allowedModels ?? []; const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null; if (rawAllowlist.length === 0) return null;
const keys = new Set<string>(); const keys = new Set<string>();
for (const raw of rawAllowlist) { for (const raw of rawAllowlist) {
@ -81,11 +84,28 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) { if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false); addRaw(params.modelOverride, false);
} else if (params.cfg?.agent?.imageModel?.trim()) { } else {
addRaw(params.cfg.agent.imageModel, false); const imageModel = params.cfg?.agent?.imageModel as
| { primary?: string }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
if (primary?.trim()) addRaw(primary, false);
} }
for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) { const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
if (imageModel && typeof imageModel === "object") {
return imageModel.fallbacks ?? [];
}
return [];
})();
for (const raw of imageFallbacks) {
addRaw(raw, true); addRaw(raw, true);
} }
@ -121,7 +141,16 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false); addCandidate({ provider, model }, false);
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) { const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as
| { fallbacks?: string[] }
| string
| undefined;
if (model && typeof model === "object") return model.fallbacks ?? [];
return [];
})();
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: String(raw ?? ""), raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
@ -224,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
}); });
if (candidates.length === 0) { if (candidates.length === 0) {
throw new Error( throw new Error(
"No image model configured. Set agent.imageModel or agent.imageModelFallbacks.", "No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
); );
} }

View File

@ -5,9 +5,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { resolveConfiguredModelRef } from "./model-selection.js"; import { resolveConfiguredModelRef } from "./model-selection.js";
describe("resolveConfiguredModelRef", () => { describe("resolveConfiguredModelRef", () => {
it("parses provider/model from agent.model", () => { it("parses provider/model from agent.model.primary", () => {
const cfg = { const cfg = {
agent: { model: "openai/gpt-4.1-mini" }, agent: { model: { primary: "openai/gpt-4.1-mini" } },
} satisfies ClawdbotConfig; } satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
@ -19,9 +19,9 @@ describe("resolveConfiguredModelRef", () => {
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" }); expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
}); });
it("falls back to anthropic when agent.model omits provider", () => { it("falls back to anthropic when agent.model.primary omits provider", () => {
const cfg = { const cfg = {
agent: { model: "claude-opus-4-5" }, agent: { model: { primary: "claude-opus-4-5" } },
} satisfies ClawdbotConfig; } satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
@ -54,9 +54,9 @@ describe("resolveConfiguredModelRef", () => {
it("resolves agent.model aliases when configured", () => { it("resolves agent.model aliases when configured", () => {
const cfg = { const cfg = {
agent: { agent: {
model: "Opus", model: { primary: "Opus" },
modelAliases: { models: {
Opus: "anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
} satisfies ClawdbotConfig; } satisfies ClawdbotConfig;
@ -72,4 +72,18 @@ describe("resolveConfiguredModelRef", () => {
model: "claude-opus-4-5", model: "claude-opus-4-5",
}); });
}); });
it("still resolves legacy agent.model string", () => {
const cfg = {
agent: { model: "openai/gpt-4.1-mini" },
} satisfies ClawdbotConfig;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
});
}); });

View File

@ -41,18 +41,17 @@ export function buildModelAliasIndex(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
defaultProvider: string; defaultProvider: string;
}): ModelAliasIndex { }): ModelAliasIndex {
const rawAliases = params.cfg.agent?.modelAliases ?? {};
const byAlias = new Map<string, { alias: string; ref: ModelRef }>(); const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>(); const byKey = new Map<string, string[]>();
for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) { const rawModels = params.cfg.agent?.models ?? {};
const alias = aliasRaw.trim(); for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
if (!alias) continue; const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
const parsed = parseModelRef(
String(targetRaw ?? ""),
params.defaultProvider,
);
if (!parsed) continue; if (!parsed) continue;
const alias = String(
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
).trim();
if (!alias) continue;
const aliasKey = normalizeAliasKey(alias); const aliasKey = normalizeAliasKey(alias);
byAlias.set(aliasKey, { alias, ref: parsed }); byAlias.set(aliasKey, { alias, ref: parsed });
const key = modelKey(parsed.provider, parsed.model); const key = modelKey(parsed.provider, parsed.model);
@ -88,7 +87,14 @@ export function resolveConfiguredModelRef(params: {
defaultProvider: string; defaultProvider: string;
defaultModel: string; defaultModel: string;
}): ModelRef { }): ModelRef {
const rawModel = params.cfg.agent?.model?.trim() || ""; const rawModel = (() => {
const raw = params.cfg.agent?.model as
| { primary?: string }
| string
| undefined;
if (typeof raw === "string") return raw.trim();
return raw?.primary?.trim() ?? "";
})();
if (rawModel) { if (rawModel) {
const trimmed = rawModel.trim(); const trimmed = rawModel.trim();
const aliasIndex = buildModelAliasIndex({ const aliasIndex = buildModelAliasIndex({
@ -116,7 +122,10 @@ export function buildAllowedModelSet(params: {
allowedCatalog: ModelCatalogEntry[]; allowedCatalog: ModelCatalogEntry[];
allowedKeys: Set<string>; allowedKeys: Set<string>;
} { } {
const rawAllowlist = params.cfg.agent?.allowedModels ?? []; const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0; const allowAny = rawAllowlist.length === 0;
const catalogKeys = new Set( const catalogKeys = new Set(
params.catalog.map((entry) => modelKey(entry.provider, entry.id)), params.catalog.map((entry) => modelKey(entry.provider, entry.id)),

View File

@ -120,12 +120,40 @@ export function isRateLimitAssistantError(
if (!msg || msg.stopReason !== "error") return false; if (!msg || msg.stopReason !== "error") return false;
const raw = (msg.errorMessage ?? "").toLowerCase(); const raw = (msg.errorMessage ?? "").toLowerCase();
if (!raw) return false; if (!raw) return false;
return isRateLimitErrorMessage(raw);
}
export function isRateLimitErrorMessage(raw: string): boolean {
const value = raw.toLowerCase();
return ( return (
/rate[_ ]limit|too many requests|429/.test(raw) || /rate[_ ]limit|too many requests|429/.test(value) ||
raw.includes("exceeded your current quota") value.includes("exceeded your current quota")
); );
} }
export function isAuthErrorMessage(raw: string): boolean {
const value = raw.toLowerCase();
if (!value) return false;
return (
/invalid[_ ]?api[_ ]?key/.test(value) ||
value.includes("incorrect api key") ||
value.includes("invalid token") ||
value.includes("authentication") ||
value.includes("unauthorized") ||
value.includes("forbidden") ||
value.includes("access denied") ||
/\b401\b/.test(value) ||
/\b403\b/.test(value)
);
}
export function isAuthAssistantError(
msg: AssistantMessage | undefined,
): boolean {
if (!msg || msg.stopReason !== "error") return false;
return isAuthErrorMessage(msg.errorMessage ?? "");
}
function extractSupportedValues(raw: string): string[] { function extractSupportedValues(raw: string): string[] {
const match = const match =
raw.match(/supported values are:\s*([^\n.]+)/i) ?? raw.match(/supported values are:\s*([^\n.]+)/i) ??

View File

@ -24,15 +24,23 @@ import {
} from "../process/command-queue.js"; } from "../process/command-queue.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { markAuthProfileGood } from "./auth-profiles.js";
import type { BashElevatedDefaults } from "./bash-tools.js"; import type { BashElevatedDefaults } from "./bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { getApiKeyForModel } from "./model-auth.js"; import {
ensureAuthProfileStore,
getApiKeyForModel,
resolveAuthProfileOrder,
} from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js"; import { ensureClawdbotModelsJson } from "./models-config.js";
import { import {
buildBootstrapContextFiles, buildBootstrapContextFiles,
ensureSessionHeader, ensureSessionHeader,
formatAssistantErrorText, formatAssistantErrorText,
isAuthAssistantError,
isAuthErrorMessage,
isRateLimitAssistantError, isRateLimitAssistantError,
isRateLimitErrorMessage,
pickFallbackThinkingLevel, pickFallbackThinkingLevel,
sanitizeSessionMessagesImages, sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js"; } from "./pi-embedded-helpers.js";
@ -311,6 +319,7 @@ export async function runEmbeddedPiAgent(params: {
prompt: string; prompt: string;
provider?: string; provider?: string;
model?: string; model?: string;
authProfileId?: string;
thinkLevel?: ThinkLevel; thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel; verboseLevel?: VerboseLevel;
bashElevated?: BashElevatedDefaults; bashElevated?: BashElevatedDefaults;
@ -368,11 +377,67 @@ export async function runEmbeddedPiAgent(params: {
if (!model) { if (!model) {
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
} }
const apiKey = await getApiKeyForModel(model, authStorage); const authStore = ensureAuthProfileStore();
authStorage.setRuntimeApiKey(model.provider, apiKey); const explicitProfileId = params.authProfileId?.trim();
const profileOrder = resolveAuthProfileOrder({
let thinkLevel = params.thinkLevel ?? "off"; cfg: params.config,
store: authStore,
provider,
preferredProfile: explicitProfileId,
});
if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
throw new Error(
`Auth profile "${explicitProfileId}" is not configured for ${provider}.`,
);
}
const profileCandidates =
profileOrder.length > 0 ? profileOrder : [undefined];
let profileIndex = 0;
const initialThinkLevel = params.thinkLevel ?? "off";
let thinkLevel = initialThinkLevel;
const attemptedThinking = new Set<ThinkLevel>(); const attemptedThinking = new Set<ThinkLevel>();
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>> | null =
null;
const resolveApiKeyForCandidate = async (candidate?: string) => {
return getApiKeyForModel({
model,
cfg: params.config,
profileId: candidate,
store: authStore,
});
};
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
};
const advanceAuthProfile = async (): Promise<boolean> => {
let nextIndex = profileIndex + 1;
while (nextIndex < profileCandidates.length) {
const candidate = profileCandidates[nextIndex];
try {
await applyApiKeyInfo(candidate);
profileIndex = nextIndex;
thinkLevel = initialThinkLevel;
attemptedThinking.clear();
return true;
} catch (err) {
if (candidate && candidate === explicitProfileId) throw err;
nextIndex += 1;
}
}
return false;
};
try {
await applyApiKeyInfo(profileCandidates[profileIndex]);
} catch (err) {
if (profileCandidates[profileIndex] === explicitProfileId) throw err;
const advanced = await advanceAuthProfile();
if (!advanced) throw err;
}
while (true) { while (true) {
const thinkingLevel = mapThinkingLevel(thinkLevel); const thinkingLevel = mapThinkingLevel(thinkLevel);
@ -611,8 +676,16 @@ export async function runEmbeddedPiAgent(params: {
params.abortSignal?.removeEventListener?.("abort", onAbort); params.abortSignal?.removeEventListener?.("abort", onAbort);
} }
if (promptError && !aborted) { if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
if (
(isAuthErrorMessage(errorText) ||
isRateLimitErrorMessage(errorText)) &&
(await advanceAuthProfile())
) {
continue;
}
const fallbackThinking = pickFallbackThinkingLevel({ const fallbackThinking = pickFallbackThinkingLevel({
message: describeUnknownError(promptError), message: errorText,
attempted: attemptedThinking, attempted: attemptedThinking,
}); });
if (fallbackThinking) { if (fallbackThinking) {
@ -645,13 +718,25 @@ export async function runEmbeddedPiAgent(params: {
} }
const fallbackConfigured = const fallbackConfigured =
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0; (params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) { const authFailure = isAuthAssistantError(lastAssistant);
const message = const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
lastAssistant?.errorMessage?.trim() || if (!aborted && (authFailure || rateLimitFailure)) {
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") || const rotated = await advanceAuthProfile();
"LLM request rate limited."; if (rotated) {
throw new Error(message); continue;
}
if (fallbackConfigured) {
const message =
lastAssistant?.errorMessage?.trim() ||
(lastAssistant
? formatAssistantErrorText(lastAssistant)
: "") ||
(rateLimitFailure
? "LLM request rate limited."
: "LLM request unauthorized.");
throw new Error(message);
}
} }
const usage = lastAssistant?.usage; const usage = lastAssistant?.usage;
@ -717,6 +802,13 @@ export async function runEmbeddedPiAgent(params: {
log.debug( log.debug(
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
); );
if (apiKeyInfo?.profileId) {
markAuthProfileGood({
store: authStore,
provider,
profileId: apiKeyInfo.profileId,
});
}
return { return {
payloads: payloads.length ? payloads : undefined, payloads: payloads.length ? payloads : undefined,
meta: { meta: {

View File

@ -24,9 +24,15 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image."; const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean { function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const primary = cfg?.agent?.imageModel?.trim(); const imageModel = cfg?.agent?.imageModel as
const fallbacks = cfg?.agent?.imageModelFallbacks ?? []; | { primary?: string; fallbacks?: string[] }
return Boolean(primary || fallbacks.length > 0); | string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const fallbacks =
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
return Boolean(primary?.trim() || fallbacks.length > 0);
} }
function pickMaxBytes( function pickMaxBytes(
@ -95,15 +101,18 @@ async function runImagePrompt(params: {
`Model does not support images: ${provider}/${modelId}`, `Model does not support images: ${provider}/${modelId}`,
); );
} }
const apiKey = await getApiKeyForModel(model, authStorage); const apiKeyInfo = await getApiKeyForModel({
authStorage.setRuntimeApiKey(model.provider, apiKey); model,
cfg: params.cfg,
});
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
const context = buildImageContext( const context = buildImageContext(
params.prompt, params.prompt,
params.base64, params.base64,
params.mimeType, params.mimeType,
); );
const message = (await complete(model, context, { const message = (await complete(model, context, {
apiKey, apiKey: apiKeyInfo.apiKey,
maxTokens: 512, maxTokens: 512,
temperature: 0, temperature: 0,
})) as AssistantMessage; })) as AssistantMessage;

View File

@ -1,19 +1,28 @@
export function extractModelDirective(body?: string): { export function extractModelDirective(body?: string): {
cleaned: string; cleaned: string;
rawModel?: string; rawModel?: string;
rawProfile?: string;
hasDirective: boolean; hasDirective: boolean;
} { } {
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
const match = body.match( const match = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?)?/i, /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
); );
const rawModel = match?.[1]?.trim(); const raw = match?.[1]?.trim();
let rawModel = raw;
let rawProfile: string | undefined;
if (raw?.includes("@")) {
const parts = raw.split("@");
rawModel = parts[0]?.trim();
rawProfile = parts.slice(1).join("@").trim() || undefined;
}
const cleaned = match const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim() ? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim(); : body.trim();
return { return {
cleaned, cleaned,
rawModel, rawModel,
rawProfile,
hasDirective: !!match, hasDirective: !!match,
}; };
} }

View File

@ -37,11 +37,24 @@ vi.mock("../agents/model-catalog.js", () => ({
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-")); const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-"));
const previousHome = process.env.HOME; const previousHome = process.env.HOME;
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = base; process.env.HOME = base;
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
try { try {
return await fn(base); return await fn(base);
} finally { } finally {
process.env.HOME = previousHome; process.env.HOME = previousHome;
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
await fs.rm(base, { recursive: true, force: true }); await fs.rm(base, { recursive: true, force: true });
} }
} }
@ -566,9 +579,12 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
session: { store: storePath }, session: { store: storePath },
}, },
@ -593,9 +609,12 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
session: { store: storePath }, session: { store: storePath },
}, },
@ -620,9 +639,12 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
session: { store: storePath }, session: { store: storePath },
}, },
@ -646,9 +668,11 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["anthropic/claude-opus-4-5"], models: {
"anthropic/claude-opus-4-5": {},
},
}, },
session: { store: storePath }, session: { store: storePath },
}, },
@ -671,9 +695,12 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini"], models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
session: { store: storePath }, session: { store: storePath },
}, },
@ -699,11 +726,11 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "openai/gpt-4.1-mini", model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], models: {
modelAliases: { "openai/gpt-4.1-mini": {},
Opus: "anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -721,6 +748,55 @@ describe("directive parsing", () => {
}); });
}); });
it("stores auth profile overrides on /model directive", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const authDir = path.join(home, ".clawdbot", "agent");
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(authDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"anthropic:work": {
type: "api_key",
provider: "anthropic",
key: "sk-test-1234567890",
},
},
},
null,
2,
),
);
const res = await getReplyFromConfig(
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
session: { store: storePath },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Auth profile set to anthropic:work");
const store = loadSessionStore(storePath);
const entry = store.main;
expect(entry.authProfileOverride).toBe("anthropic:work");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("queues a system event when switching models", async () => { it("queues a system event when switching models", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
drainSystemEvents(); drainSystemEvents();
@ -732,11 +808,11 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "openai/gpt-4.1-mini", model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], models: {
modelAliases: { "openai/gpt-4.1-mini": {},
Opus: "anthropic/claude-opus-4-5", "anthropic/claude-opus-4-5": { alias: "Opus" },
}, },
}, },
session: { store: storePath }, session: { store: storePath },
@ -771,9 +847,12 @@ describe("directive parsing", () => {
{}, {},
{ {
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini"], models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
}, },
whatsapp: { whatsapp: {
allowFrom: ["*"], allowFrom: ["*"],

View File

@ -361,7 +361,9 @@ export async function getReplyFromConfig(
: `Model switched to ${label}.`; : `Model switched to ${label}.`;
const isModelListAlias = const isModelListAlias =
directives.hasModelDirective && directives.hasModelDirective &&
directives.rawModelDirective?.trim().toLowerCase() === "status"; ["status", "list"].includes(
directives.rawModelDirective?.trim().toLowerCase() ?? "",
);
const effectiveModelDirective = isModelListAlias const effectiveModelDirective = isModelListAlias
? undefined ? undefined
: directives.rawModelDirective; : directives.rawModelDirective;
@ -376,6 +378,7 @@ export async function getReplyFromConfig(
}) })
) { ) {
const directiveReply = await handleDirectiveOnly({ const directiveReply = await handleDirectiveOnly({
cfg,
directives, directives,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
@ -401,6 +404,7 @@ export async function getReplyFromConfig(
const persisted = await persistInlineDirectives({ const persisted = await persistInlineDirectives({
directives, directives,
effectiveModelDirective, effectiveModelDirective,
cfg,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@ -634,6 +638,7 @@ export async function getReplyFromConfig(
resolvedQueue.mode === "followup" || resolvedQueue.mode === "followup" ||
resolvedQueue.mode === "collect" || resolvedQueue.mode === "collect" ||
resolvedQueue.mode === "steer-backlog"; resolvedQueue.mode === "steer-backlog";
const authProfileId = sessionEntry?.authProfileOverride;
const followupRun = { const followupRun = {
prompt: queuedBody, prompt: queuedBody,
summaryLine: baseBodyTrimmedRaw, summaryLine: baseBodyTrimmedRaw,
@ -648,6 +653,7 @@ export async function getReplyFromConfig(
skillsSnapshot, skillsSnapshot,
provider, provider,
model, model,
authProfileId,
thinkLevel: resolvedThinkLevel, thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel, verboseLevel: resolvedVerboseLevel,
elevatedLevel: resolvedElevatedLevel, elevatedLevel: resolvedElevatedLevel,

View File

@ -195,6 +195,7 @@ export async function runReplyAgent(params: {
enforceFinalTag: followupRun.run.enforceFinalTag, enforceFinalTag: followupRun.run.enforceFinalTag,
provider, provider,
model, model,
authProfileId: followupRun.run.authProfileId,
thinkLevel: followupRun.run.thinkLevel, thinkLevel: followupRun.run.thinkLevel,
verboseLevel: followupRun.run.verboseLevel, verboseLevel: followupRun.run.verboseLevel,
bashElevated: followupRun.run.bashElevated, bashElevated: followupRun.run.bashElevated,

View File

@ -1,10 +1,12 @@
import fs from "node:fs"; import {
ensureAuthProfileStore,
import { getEnvApiKey } from "@mariozechner/pi-ai"; listProfilesForProvider,
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; } from "../../agents/auth-profiles.js";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveOAuthPath } from "../../config/paths.js";
import { import {
type SessionEntry, type SessionEntry,
type SessionScope, type SessionScope,
@ -42,55 +44,32 @@ export type CommandContext = {
to?: string; to?: string;
}; };
function hasOAuthCredentials(provider: string): boolean { function resolveModelAuthLabel(
try { provider?: string,
const oauthPath = resolveOAuthPath(); cfg?: ClawdbotConfig,
if (!fs.existsSync(oauthPath)) return false; ): string | undefined {
const raw = fs.readFileSync(oauthPath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
const entry = parsed?.[provider] as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access =
entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
} catch {
return false;
}
}
function resolveModelAuthLabel(provider?: string): string | undefined {
const resolved = provider?.trim(); const resolved = provider?.trim();
if (!resolved) return undefined; if (!resolved) return undefined;
try { const store = ensureAuthProfileStore();
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); const profiles = listProfilesForProvider(store, resolved);
const stored = authStorage.get(resolved); if (profiles.length > 0) {
if (stored?.type === "oauth") return "oauth"; const modes = new Set(
if (stored?.type === "api_key") return "api-key"; profiles
} catch { .map((id) => store.profiles[id]?.type)
// ignore auth storage errors .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
);
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("api_key")) return "api-key";
} }
if (resolved === "anthropic") { const envKey = resolveEnvApiKey(resolved);
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; if (envKey?.apiKey) {
if (oauthEnv?.trim()) return "oauth"; return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
} }
if (hasOAuthCredentials(resolved)) return "oauth"; if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
const envKey = getEnvApiKey(resolved);
if (envKey?.trim()) return "api-key";
return "unknown"; return "unknown";
} }
@ -374,7 +353,7 @@ export async function handleCommands(params: {
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel, resolvedVerbose: resolvedVerboseLevel,
resolvedElevated: resolvedElevatedLevel, resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider), modelAuth: resolveModelAuthLabel(provider, cfg),
webLinked, webLinked,
webAuthAgeMs, webAuthAgeMs,
heartbeatSeconds, heartbeatSeconds,

View File

@ -1,13 +1,20 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
} from "../../agents/auth-profiles.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
import { import {
DEFAULT_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL, DEFAULT_MODEL,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
} from "../../agents/defaults.js"; } from "../../agents/defaults.js";
import { hydrateAuthStorage } from "../../agents/model-auth.js"; import {
ensureAuthProfileStore,
getCustomProviderApiKey,
resolveAuthProfileOrder,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { import {
buildModelAliasIndex, buildModelAliasIndex,
type ModelAliasIndex, type ModelAliasIndex,
@ -53,43 +60,63 @@ const maskApiKey = (value: string): string => {
const resolveAuthLabel = async ( const resolveAuthLabel = async (
provider: string, provider: string,
authStorage: ReturnType<typeof discoverAuthStorage>, cfg: ClawdbotConfig,
authPaths: { authPath: string; modelsPath: string }, modelsPath: string,
): Promise<{ label: string; source: string }> => { ): Promise<{ label: string; source: string }> => {
const formatPath = (value: string) => shortenHomePath(value); const formatPath = (value: string) => shortenHomePath(value);
const stored = authStorage.get(provider); const store = ensureAuthProfileStore();
if (stored?.type === "oauth") { const order = resolveAuthProfileOrder({ cfg, store, provider });
const email = stored.email?.trim(); if (order.length > 0) {
const labels = order.map((profileId) => {
const profile = store.profiles[profileId];
const configProfile = cfg.auth?.profiles?.[profileId];
if (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode && configProfile.mode !== profile.type)
) {
return `${profileId}=missing`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,
store,
profileId,
});
const suffix =
display === profileId
? ""
: display.startsWith(profileId)
? display.slice(profileId.length).trim()
: `(${display})`;
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
});
return { return {
label: email ? `OAuth ${email}` : "OAuth (unknown)", label: labels.join(", "),
source: `auth.json: ${formatPath(authPaths.authPath)}`, source: `auth-profiles.json: ${formatPath(
resolveAuthStorePathForDisplay(),
)}`,
}; };
} }
if (stored?.type === "api_key") {
const envKey = resolveEnvApiKey(provider);
if (envKey) {
const isOAuthEnv =
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth");
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
return { label, source: envKey.source };
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return { return {
label: maskApiKey(stored.key), label: maskApiKey(customKey),
source: `auth.json: ${formatPath(authPaths.authPath)}`, source: `models.json: ${formatPath(modelsPath)}`,
}; };
} }
const envKey = getEnvApiKey(provider);
if (envKey) return { label: maskApiKey(envKey), source: "env" };
if (provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (oauthEnv) {
return { label: "OAuth (env)", source: "env: ANTHROPIC_OAUTH_TOKEN" };
}
}
try {
const key = await authStorage.getApiKey(provider);
if (key) {
return {
label: maskApiKey(key),
source: `models.json: ${formatPath(authPaths.modelsPath)}`,
};
}
} catch {
// ignore missing auth
}
return { label: "missing", source: "missing" }; return { label: "missing", source: "missing" };
}; };
@ -100,6 +127,26 @@ const formatAuthLabel = (auth: { label: string; source: string }) => {
return `${auth.label} (${auth.source})`; return `${auth.label} (${auth.source})`;
}; };
const resolveProfileOverride = (params: {
rawProfile?: string;
provider: string;
cfg: ClawdbotConfig;
}): { profileId?: string; error?: string } => {
const raw = params.rawProfile?.trim();
if (!raw) return {};
const store = ensureAuthProfileStore();
const profile = store.profiles[raw];
if (!profile) {
return { error: `Auth profile "${raw}" not found.` };
}
if (profile.provider !== params.provider) {
return {
error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`,
};
}
return { profileId: raw };
};
export type InlineDirectives = { export type InlineDirectives = {
cleaned: string; cleaned: string;
hasThinkDirective: boolean; hasThinkDirective: boolean;
@ -114,6 +161,7 @@ export type InlineDirectives = {
hasStatusDirective: boolean; hasStatusDirective: boolean;
hasModelDirective: boolean; hasModelDirective: boolean;
rawModelDirective?: string; rawModelDirective?: string;
rawModelProfile?: string;
hasQueueDirective: boolean; hasQueueDirective: boolean;
queueMode?: QueueMode; queueMode?: QueueMode;
queueReset: boolean; queueReset: boolean;
@ -151,6 +199,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
const { const {
cleaned: modelCleaned, cleaned: modelCleaned,
rawModel, rawModel,
rawProfile,
hasDirective: hasModelDirective, hasDirective: hasModelDirective,
} = extractModelDirective(statusCleaned); } = extractModelDirective(statusCleaned);
const { const {
@ -182,6 +231,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
hasStatusDirective, hasStatusDirective,
hasModelDirective, hasModelDirective,
rawModelDirective: rawModel, rawModelDirective: rawModel,
rawModelProfile: rawProfile,
hasQueueDirective, hasQueueDirective,
queueMode, queueMode,
queueReset, queueReset,
@ -218,6 +268,7 @@ export function isDirectiveOnly(params: {
} }
export async function handleDirectiveOnly(params: { export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
@ -265,19 +316,14 @@ export async function handleDirectiveOnly(params: {
return { text: "No models available." }; return { text: "No models available." };
} }
const agentDir = resolveClawdbotAgentDir(); const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir); const modelsPath = `${agentDir}/models.json`;
const authPaths = {
authPath: `${agentDir}/auth.json`,
modelsPath: `${agentDir}/models.json`,
};
hydrateAuthStorage(authStorage);
const authByProvider = new Map<string, string>(); const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) { for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue; if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel( const auth = await resolveAuthLabel(
entry.provider, entry.provider,
authStorage, params.cfg,
authPaths, modelsPath,
); );
authByProvider.set(entry.provider, formatAuthLabel(auth)); authByProvider.set(entry.provider, formatAuthLabel(auth));
} }
@ -306,6 +352,9 @@ export async function handleDirectiveOnly(params: {
} }
return { text: lines.join("\n") }; return { text: lines.join("\n") };
} }
if (directives.rawModelProfile && !modelDirective) {
throw new Error("Auth profile override requires a model selection.");
}
} }
if (directives.hasThinkDirective && !directives.thinkLevel) { if (directives.hasThinkDirective && !directives.thinkLevel) {
@ -378,6 +427,7 @@ export async function handleDirectiveOnly(params: {
} }
let modelSelection: ModelDirectiveSelection | undefined; let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) { if (directives.hasModelDirective && directives.rawModelDirective) {
const resolved = resolveModelDirectiveSelection({ const resolved = resolveModelDirectiveSelection({
raw: directives.rawModelDirective, raw: directives.rawModelDirective,
@ -391,6 +441,17 @@ export async function handleDirectiveOnly(params: {
} }
modelSelection = resolved.selection; modelSelection = resolved.selection;
if (modelSelection) { if (modelSelection) {
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
});
if (profileResolved.error) {
return { text: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) { if (nextLabel !== initialModelLabel) {
enqueueSystemEvent( enqueueSystemEvent(
@ -402,6 +463,9 @@ export async function handleDirectiveOnly(params: {
} }
} }
} }
if (directives.rawModelProfile && !modelSelection) {
return { text: "Auth profile override requires a model selection." };
}
if (sessionEntry && sessionStore && sessionKey) { if (sessionEntry && sessionStore && sessionKey) {
if (directives.hasThinkDirective && directives.thinkLevel) { if (directives.hasThinkDirective && directives.thinkLevel) {
@ -424,6 +488,11 @@ export async function handleDirectiveOnly(params: {
sessionEntry.providerOverride = modelSelection.provider; sessionEntry.providerOverride = modelSelection.provider;
sessionEntry.modelOverride = modelSelection.model; sessionEntry.modelOverride = modelSelection.model;
} }
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
}
} }
if (directives.hasQueueDirective && directives.queueReset) { if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode; delete sessionEntry.queueMode;
@ -481,6 +550,9 @@ export async function handleDirectiveOnly(params: {
? `Model reset to default (${labelWithAlias}).` ? `Model reset to default (${labelWithAlias}).`
: `Model set to ${labelWithAlias}.`, : `Model set to ${labelWithAlias}.`,
); );
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
} }
if (directives.hasQueueDirective && directives.queueMode) { if (directives.hasQueueDirective && directives.queueMode) {
parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`); parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`);
@ -508,6 +580,7 @@ export async function handleDirectiveOnly(params: {
export async function persistInlineDirectives(params: { export async function persistInlineDirectives(params: {
directives: InlineDirectives; directives: InlineDirectives;
effectiveModelDirective?: string; effectiveModelDirective?: string;
cfg: ClawdbotConfig;
sessionEntry?: SessionEntry; sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey?: string; sessionKey?: string;
@ -526,6 +599,7 @@ export async function persistInlineDirectives(params: {
}): Promise<{ provider: string; model: string; contextTokens: number }> { }): Promise<{ provider: string; model: string; contextTokens: number }> {
const { const {
directives, directives,
cfg,
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
@ -586,6 +660,18 @@ export async function persistInlineDirectives(params: {
if (resolved) { if (resolved) {
const key = modelKey(resolved.ref.provider, resolved.ref.model); const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
let profileOverride: string | undefined;
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: directives.rawModelProfile,
provider: resolved.ref.provider,
cfg,
});
if (profileResolved.error) {
throw new Error(profileResolved.error);
}
profileOverride = profileResolved.profileId;
}
const isDefault = const isDefault =
resolved.ref.provider === defaultProvider && resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel; resolved.ref.model === defaultModel;
@ -596,6 +682,11 @@ export async function persistInlineDirectives(params: {
sessionEntry.providerOverride = resolved.ref.provider; sessionEntry.providerOverride = resolved.ref.provider;
sessionEntry.modelOverride = resolved.ref.model; sessionEntry.modelOverride = resolved.ref.model;
} }
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
}
provider = resolved.ref.provider; provider = resolved.ref.provider;
model = resolved.ref.model; model = resolved.ref.model;
const nextLabel = `${provider}/${model}`; const nextLabel = `${provider}/${model}`;

View File

@ -84,6 +84,7 @@ export function createFollowupRunner(params: {
enforceFinalTag: queued.run.enforceFinalTag, enforceFinalTag: queued.run.enforceFinalTag,
provider, provider,
model, model,
authProfileId: queued.run.authProfileId,
thinkLevel: queued.run.thinkLevel, thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel, verboseLevel: queued.run.verboseLevel,
bashElevated: queued.run.bashElevated, bashElevated: queued.run.bashElevated,

View File

@ -57,7 +57,8 @@ export async function createModelSelectionState(params: {
let provider = params.provider; let provider = params.provider;
let model = params.model; let model = params.model;
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; const hasAllowlist =
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean( const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride, sessionEntry?.modelOverride || sessionEntry?.providerOverride,
); );
@ -110,6 +111,27 @@ export async function createModelSelectionState(params: {
} }
} }
if (
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.authProfileOverride
) {
const { ensureAuthProfileStore } = await import(
"../../agents/auth-profiles.js"
);
const store = ensureAuthProfileStore();
const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
let defaultThinkingLevel: ThinkLevel | undefined; let defaultThinkingLevel: ThinkLevel | undefined;
const resolveDefaultThinkingLevel = async () => { const resolveDefaultThinkingLevel = async () => {
if (defaultThinkingLevel) return defaultThinkingLevel; if (defaultThinkingLevel) return defaultThinkingLevel;

View File

@ -32,6 +32,7 @@ export type FollowupRun = {
skillsSnapshot?: SkillSnapshot; skillsSnapshot?: SkillSnapshot;
provider: string; provider: string;
model: string; model: string;
authProfileId?: string;
thinkLevel?: ThinkLevel; thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel; verboseLevel?: VerboseLevel;
elevatedLevel?: ElevatedLevel; elevatedLevel?: ElevatedLevel;

View File

@ -59,7 +59,8 @@ function mockConfig(
) { ) {
configSpy.mockReturnValue({ configSpy.mockReturnValue({
agent: { agent: {
model: "anthropic/claude-opus-4-5", model: { primary: "anthropic/claude-opus-4-5" },
models: { "anthropic/claude-opus-4-5": {} },
workspace: path.join(home, "clawd"), workspace: path.join(home, "clawd"),
...agentOverrides, ...agentOverrides,
}, },

View File

@ -1,4 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { lookupContextTokens } from "../agents/context.js"; import { lookupContextTokens } from "../agents/context.js";
import { import {
DEFAULT_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS,
@ -289,7 +290,8 @@ export async function agentCommand(
}); });
let provider = defaultProvider; let provider = defaultProvider;
let model = defaultModel; let model = defaultModel;
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; const hasAllowlist =
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
const hasStoredOverride = Boolean( const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride, sessionEntry?.modelOverride || sessionEntry?.providerOverride,
); );
@ -335,6 +337,18 @@ export async function agentCommand(
model = storedModelOverride; model = storedModelOverride;
} }
} }
if (sessionEntry?.authProfileOverride) {
const store = ensureAuthProfileStore();
const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride;
sessionEntry.updatedAt = Date.now();
if (sessionStore && sessionKey) {
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
}
if (!resolvedThinkLevel) { if (!resolvedThinkLevel) {
let catalogForThinking = modelCatalog ?? allowedModelCatalog; let catalogForThinking = modelCatalog ?? allowedModelCatalog;
@ -381,6 +395,7 @@ export async function agentCommand(
prompt: body, prompt: body,
provider: providerOverride, provider: providerOverride,
model: modelOverride, model: modelOverride,
authProfileId: sessionEntry?.authProfileOverride,
thinkLevel: resolvedThinkLevel, thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel, verboseLevel: resolvedVerboseLevel,
timeoutMs, timeoutMs,

View File

@ -32,6 +32,7 @@ import {
} from "./antigravity-oauth.js"; } from "./antigravity-oauth.js";
import { healthCommand } from "./health.js"; import { healthCommand } from "./health.js";
import { import {
applyAuthProfileConfig,
applyMinimaxConfig, applyMinimaxConfig,
setAnthropicApiKey, setAnthropicApiKey,
writeOAuthCredentials, writeOAuthCredentials,
@ -275,6 +276,11 @@ async function promptAuthConfig(
spin.stop("OAuth complete"); spin.stop("OAuth complete");
if (oauthCreds) { if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds); await writeOAuthCredentials("anthropic", oauthCreds);
next = applyAuthProfileConfig(next, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "oauth",
});
} }
} catch (err) { } catch (err) {
spin.stop("OAuth failed"); spin.stop("OAuth failed");
@ -316,12 +322,30 @@ async function promptAuthConfig(
spin.stop("Antigravity OAuth complete"); spin.stop("Antigravity OAuth complete");
if (oauthCreds) { if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds); await writeOAuthCredentials("google-antigravity", oauthCreds);
next = applyAuthProfileConfig(next, {
profileId: "google-antigravity:default",
provider: "google-antigravity",
mode: "oauth",
});
// Set default model to Claude Opus 4.5 via Antigravity // Set default model to Claude Opus 4.5 via Antigravity
next = { next = {
...next, ...next,
agent: { agent: {
...next.agent, ...next.agent,
model: "google-antigravity/claude-opus-4-5-thinking", model: {
...((next.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...next.agent?.models,
"google-antigravity/claude-opus-4-5-thinking":
next.agent?.models?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
},
}, },
}; };
note( note(
@ -342,6 +366,11 @@ async function promptAuthConfig(
runtime, runtime,
); );
await setAnthropicApiKey(String(key).trim()); await setAnthropicApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "minimax") { } else if (authChoice === "minimax") {
next = applyMinimaxConfig(next); next = applyMinimaxConfig(next);
} }
@ -349,7 +378,10 @@ async function promptAuthConfig(
const modelInput = guardCancel( const modelInput = guardCancel(
await text({ await text({
message: "Default model (blank to keep)", message: "Default model (blank to keep)",
initialValue: next.agent?.model ?? "", initialValue:
typeof next.agent?.model === "string"
? next.agent?.model
: (next.agent?.model?.primary ?? ""),
}), }),
runtime, runtime,
); );
@ -359,7 +391,17 @@ async function promptAuthConfig(
...next, ...next,
agent: { agent: {
...next.agent, ...next.agent,
model, model: {
...((next.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
primary: model,
},
models: {
...next.agent?.models,
[model]: next.agent?.models?.[model] ?? {},
},
}, },
}; };
} }

View File

@ -13,7 +13,15 @@ export async function modelsAliasesListCommand(
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
const cfg = loadConfig(); const cfg = loadConfig();
const aliases = cfg.agent?.modelAliases ?? {}; const models = cfg.agent?.models ?? {};
const aliases = Object.entries(models).reduce<Record<string, string>>(
(acc, [modelKey, entry]) => {
const alias = entry?.alias?.trim();
if (alias) acc[alias] = modelKey;
return acc;
},
{},
);
if (opts.json) { if (opts.json) {
runtime.log(JSON.stringify({ aliases }, null, 2)); runtime.log(JSON.stringify({ aliases }, null, 2));
@ -42,21 +50,29 @@ export async function modelsAliasesAddCommand(
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const alias = normalizeAlias(aliasRaw); const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() });
const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const _updated = await updateConfig((cfg) => {
const nextAliases = { ...cfg.agent?.modelAliases }; const modelKey = `${resolved.provider}/${resolved.model}`;
nextAliases[alias] = `${resolved.provider}/${resolved.model}`; const nextModels = { ...cfg.agent?.models };
for (const [key, entry] of Object.entries(nextModels)) {
const existing = entry?.alias?.trim();
if (existing && existing === alias && key !== modelKey) {
throw new Error(`Alias ${alias} already points to ${key}.`);
}
}
const existing = nextModels[modelKey] ?? {};
nextModels[modelKey] = { ...existing, alias };
return { return {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
modelAliases: nextAliases, models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`); runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
} }
export async function modelsAliasesRemoveCommand( export async function modelsAliasesRemoveCommand(
@ -65,24 +81,31 @@ export async function modelsAliasesRemoveCommand(
) { ) {
const alias = normalizeAlias(aliasRaw); const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => { const updated = await updateConfig((cfg) => {
const nextAliases = { ...cfg.agent?.modelAliases }; const nextModels = { ...cfg.agent?.models };
if (!nextAliases[alias]) { let found = false;
for (const [key, entry] of Object.entries(nextModels)) {
if (entry?.alias?.trim() === alias) {
nextModels[key] = { ...entry, alias: undefined };
found = true;
break;
}
}
if (!found) {
throw new Error(`Alias not found: ${alias}`); throw new Error(`Alias not found: ${alias}`);
} }
delete nextAliases[alias];
return { return {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
modelAliases: nextAliases, models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if ( if (
!updated.agent?.modelAliases || !updated.agent?.models ||
Object.keys(updated.agent.modelAliases).length === 0 Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim())
) { ) {
runtime.log("No aliases configured."); runtime.log("No aliases configured.");
} }

View File

@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand(
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
const cfg = loadConfig(); const cfg = loadConfig();
const fallbacks = cfg.agent?.modelFallbacks ?? []; const fallbacks = cfg.agent?.model?.fallbacks ?? [];
if (opts.json) { if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2)); runtime.log(JSON.stringify({ fallbacks }, null, 2));
@ -44,11 +44,13 @@ export async function modelsFallbacksAddCommand(
const updated = await updateConfig((cfg) => { const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model); const targetKey = modelKey(resolved.provider, resolved.model);
const nextModels = { ...cfg.agent?.models };
if (!nextModels[targetKey]) nextModels[targetKey] = {};
const aliasIndex = buildModelAliasIndex({ const aliasIndex = buildModelAliasIndex({
cfg, cfg,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
}); });
const existing = cfg.agent?.modelFallbacks ?? []; const existing = cfg.agent?.model?.fallbacks ?? [];
const existingKeys = existing const existingKeys = existing
.map((entry) => .map((entry) =>
resolveModelRefFromString({ resolveModelRefFromString({
@ -66,13 +68,22 @@ export async function modelsFallbacksAddCommand(
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
modelFallbacks: [...existing, targetKey], model: {
...((cfg.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: [...existing, targetKey],
},
models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); runtime.log(
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
);
} }
export async function modelsFallbacksRemoveCommand( export async function modelsFallbacksRemoveCommand(
@ -86,7 +97,7 @@ export async function modelsFallbacksRemoveCommand(
cfg, cfg,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
}); });
const existing = cfg.agent?.modelFallbacks ?? []; const existing = cfg.agent?.model?.fallbacks ?? [];
const filtered = existing.filter((entry) => { const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({ const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""), raw: String(entry ?? ""),
@ -108,13 +119,21 @@ export async function modelsFallbacksRemoveCommand(
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
modelFallbacks: filtered, model: {
...((cfg.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: filtered,
},
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); runtime.log(
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
);
} }
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
@ -122,7 +141,11 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
modelFallbacks: [], model: {
...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ??
{}),
fallbacks: [],
},
}, },
})); }));

View File

@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand(
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
const cfg = loadConfig(); const cfg = loadConfig();
const fallbacks = cfg.agent?.imageModelFallbacks ?? []; const fallbacks = cfg.agent?.imageModel?.fallbacks ?? [];
if (opts.json) { if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2)); runtime.log(JSON.stringify({ fallbacks }, null, 2));
@ -44,11 +44,13 @@ export async function modelsImageFallbacksAddCommand(
const updated = await updateConfig((cfg) => { const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model); const targetKey = modelKey(resolved.provider, resolved.model);
const nextModels = { ...cfg.agent?.models };
if (!nextModels[targetKey]) nextModels[targetKey] = {};
const aliasIndex = buildModelAliasIndex({ const aliasIndex = buildModelAliasIndex({
cfg, cfg,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
}); });
const existing = cfg.agent?.imageModelFallbacks ?? []; const existing = cfg.agent?.imageModel?.fallbacks ?? [];
const existingKeys = existing const existingKeys = existing
.map((entry) => .map((entry) =>
resolveModelRefFromString({ resolveModelRefFromString({
@ -66,14 +68,21 @@ export async function modelsImageFallbacksAddCommand(
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
imageModelFallbacks: [...existing, targetKey], imageModel: {
...((cfg.agent?.imageModel as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: [...existing, targetKey],
},
models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log( runtime.log(
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
); );
} }
@ -88,7 +97,7 @@ export async function modelsImageFallbacksRemoveCommand(
cfg, cfg,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
}); });
const existing = cfg.agent?.imageModelFallbacks ?? []; const existing = cfg.agent?.imageModel?.fallbacks ?? [];
const filtered = existing.filter((entry) => { const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({ const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""), raw: String(entry ?? ""),
@ -110,14 +119,20 @@ export async function modelsImageFallbacksRemoveCommand(
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
imageModelFallbacks: filtered, imageModel: {
...((cfg.agent?.imageModel as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: filtered,
},
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log( runtime.log(
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
); );
} }
@ -126,7 +141,13 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
imageModelFallbacks: [], imageModel: {
...((cfg.agent?.imageModel as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: [],
},
}, },
})); }));

View File

@ -1,4 +1,4 @@
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai"; import type { Api, Model } from "@mariozechner/pi-ai";
import { import {
discoverAuthStorage, discoverAuthStorage,
discoverModels, discoverModels,
@ -6,6 +6,15 @@ import {
import chalk from "chalk"; import chalk from "chalk";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
listProfilesForProvider,
} from "../../agents/auth-profiles.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { import {
buildModelAliasIndex, buildModelAliasIndex,
parseModelRef, parseModelRef,
@ -81,6 +90,17 @@ const isLocalBaseUrl = (baseUrl: string) => {
} }
}; };
const hasAuthForProvider = (
provider: string,
cfg: ClawdbotConfig,
authStore: AuthProfileStore,
): boolean => {
if (listProfilesForProvider(authStore, provider).length > 0) return true;
if (resolveEnvApiKey(provider)) return true;
if (getCustomProviderApiKey(cfg, provider)) return true;
return false;
};
const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
const resolvedDefault = resolveConfiguredModelRef({ const resolvedDefault = resolveConfiguredModelRef({
cfg, cfg,
@ -110,7 +130,21 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
addEntry(resolvedDefault, "default"); addEntry(resolvedDefault, "default");
(cfg.agent?.modelFallbacks ?? []).forEach((raw, idx) => { const modelConfig = cfg.agent?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
const imageModelConfig = cfg.agent?.imageModel as
| { primary?: string; fallbacks?: string[] }
| undefined;
const modelFallbacks =
typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
const imageFallbacks =
typeof imageModelConfig === "object"
? (imageModelConfig?.fallbacks ?? [])
: [];
const imagePrimary = imageModelConfig?.primary?.trim() ?? "";
modelFallbacks.forEach((raw, idx) => {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: String(raw ?? ""), raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
@ -120,17 +154,16 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
addEntry(resolved.ref, `fallback#${idx + 1}`); addEntry(resolved.ref, `fallback#${idx + 1}`);
}); });
const imageModelRaw = cfg.agent?.imageModel?.trim(); if (imagePrimary) {
if (imageModelRaw) {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: imageModelRaw, raw: imagePrimary,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
aliasIndex, aliasIndex,
}); });
if (resolved) addEntry(resolved.ref, "image"); if (resolved) addEntry(resolved.ref, "image");
} }
(cfg.agent?.imageModelFallbacks ?? []).forEach((raw, idx) => { imageFallbacks.forEach((raw, idx) => {
const resolved = resolveModelRefFromString({ const resolved = resolveModelRefFromString({
raw: String(raw ?? ""), raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
@ -140,20 +173,10 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
addEntry(resolved.ref, `img-fallback#${idx + 1}`); addEntry(resolved.ref, `img-fallback#${idx + 1}`);
}); });
(cfg.agent?.allowedModels ?? []).forEach((raw) => { for (const key of Object.keys(cfg.agent?.models ?? {})) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER);
if (!parsed) return; if (!parsed) continue;
addEntry(parsed, "allowed"); addEntry(parsed, "configured");
});
for (const targetRaw of Object.values(cfg.agent?.modelAliases ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(targetRaw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) continue;
addEntry(resolved.ref, "alias");
} }
const entries: ConfiguredEntry[] = order.map((key) => { const entries: ConfiguredEntry[] = order.map((key) => {
@ -190,8 +213,18 @@ function toModelRow(params: {
tags: string[]; tags: string[];
aliases?: string[]; aliases?: string[];
availableKeys?: Set<string>; availableKeys?: Set<string>;
cfg?: ClawdbotConfig;
authStore?: AuthProfileStore;
}): ModelRow { }): ModelRow {
const { model, key, tags, aliases = [], availableKeys } = params; const {
model,
key,
tags,
aliases = [],
availableKeys,
cfg,
authStore,
} = params;
if (!model) { if (!model) {
return { return {
key, key,
@ -207,9 +240,11 @@ function toModelRow(params: {
const input = model.input.join("+") || "text"; const input = model.input.join("+") || "text";
const local = isLocalBaseUrl(model.baseUrl); const local = isLocalBaseUrl(model.baseUrl);
const envKey = getEnvApiKey(model.provider);
const available = const available =
availableKeys?.has(modelKey(model.provider, model.id)) || Boolean(envKey); availableKeys?.has(modelKey(model.provider, model.id)) ||
(cfg && authStore
? hasAuthForProvider(model.provider, cfg, authStore)
: false);
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
const mergedTags = new Set(tags); const mergedTags = new Set(tags);
if (aliasTags.length > 0) { if (aliasTags.length > 0) {
@ -304,6 +339,7 @@ export async function modelsListCommand(
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
const cfg = loadConfig(); const cfg = loadConfig();
const authStore = ensureAuthProfileStore();
const providerFilter = opts.provider?.trim().toLowerCase(); const providerFilter = opts.provider?.trim().toLowerCase();
let models: Model<Api>[] = []; let models: Model<Api>[] = [];
@ -346,6 +382,8 @@ export async function modelsListCommand(
tags: configured ? Array.from(configured.tags) : [], tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [], aliases: configured?.aliases ?? [],
availableKeys, availableKeys,
cfg,
authStore,
}), }),
); );
} }
@ -367,6 +405,8 @@ export async function modelsListCommand(
tags: Array.from(entry.tags), tags: Array.from(entry.tags),
aliases: entry.aliases, aliases: entry.aliases,
availableKeys, availableKeys,
cfg,
authStore,
}), }),
); );
} }
@ -392,13 +432,35 @@ export async function modelsStatusCommand(
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
}); });
const rawModel = cfg.agent?.model?.trim() ?? ""; const modelConfig = cfg.agent?.model as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const imageConfig = cfg.agent?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const rawModel =
typeof modelConfig === "string"
? modelConfig.trim()
: (modelConfig?.primary?.trim() ?? "");
const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`; const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`;
const fallbacks = cfg.agent?.modelFallbacks ?? []; const fallbacks =
const imageModel = cfg.agent?.imageModel?.trim() ?? ""; typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
const imageFallbacks = cfg.agent?.imageModelFallbacks ?? []; const imageModel =
const aliases = cfg.agent?.modelAliases ?? {}; typeof imageConfig === "string"
const allowed = cfg.agent?.allowedModels ?? []; ? imageConfig.trim()
: (imageConfig?.primary?.trim() ?? "");
const imageFallbacks =
typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
const aliases = Object.entries(cfg.agent?.models ?? {}).reduce<
Record<string, string>
>((acc, [key, entry]) => {
const alias = entry?.alias?.trim();
if (alias) acc[alias] = key;
return acc;
}, {});
const allowed = Object.keys(cfg.agent?.models ?? {});
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
@ -446,6 +508,8 @@ export async function modelsStatusCommand(
}`, }`,
); );
runtime.log( runtime.log(
`Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`, `Configured models (${allowed.length || 0}): ${
allowed.length ? allowed.join(", ") : "all"
}`,
); );
} }

View File

@ -1,20 +1,12 @@
import { cancel, isCancel, multiselect } from "@clack/prompts"; import { cancel, isCancel, multiselect } from "@clack/prompts";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { import {
type ModelScanResult, type ModelScanResult,
scanOpenRouterModels, scanOpenRouterModels,
} from "../../agents/model-scan.js"; } from "../../agents/model-scan.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { warn } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import { formatMs, formatTokenK, updateConfig } from "./shared.js";
buildAllowlistSet,
formatMs,
formatTokenK,
updateConfig,
} from "./shared.js";
const MODEL_PAD = 42; const MODEL_PAD = 42;
const CTX_PAD = 8; const CTX_PAD = 8;
@ -181,8 +173,17 @@ export async function modelsScanCommand(
throw new Error("--concurrency must be > 0"); throw new Error("--concurrency must be > 0");
} }
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); const cfg = loadConfig();
const storedKey = await authStorage.getApiKey("openrouter"); let storedKey: string | undefined;
try {
const resolved = await resolveApiKeyForProvider({
provider: "openrouter",
cfg,
});
storedKey = resolved.apiKey;
} catch {
storedKey = undefined;
}
const results = await scanOpenRouterModels({ const results = await scanOpenRouterModels({
apiKey: storedKey ?? undefined, apiKey: storedKey ?? undefined,
minParamB: minParams, minParamB: minParams,
@ -266,32 +267,42 @@ export async function modelsScanCommand(
throw new Error("No image-capable models selected for image model."); throw new Error("No image-capable models selected for image model.");
} }
const updated = await updateConfig((cfg) => { const _updated = await updateConfig((cfg) => {
const nextModels = { ...cfg.agent?.models };
for (const entry of selected) {
if (!nextModels[entry]) nextModels[entry] = {};
}
for (const entry of selectedImages) {
if (!nextModels[entry]) nextModels[entry] = {};
}
const nextImageModel =
selectedImages.length > 0
? {
...((cfg.agent?.imageModel as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
fallbacks: selectedImages,
...(opts.setImage ? { primary: selectedImages[0] } : {}),
}
: cfg.agent?.imageModel;
const agent = { const agent = {
...cfg.agent, ...cfg.agent,
modelFallbacks: selected, model: {
...(opts.setDefault ? { model: selected[0] } : {}), ...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ??
...(opts.setImage && selectedImages.length > 0 {}),
? { imageModel: selectedImages[0] } fallbacks: selected,
: {}), ...(opts.setDefault ? { primary: selected[0] } : {}),
},
...(nextImageModel ? { imageModel: nextImageModel } : {}),
models: nextModels,
} satisfies NonNullable<typeof cfg.agent>; } satisfies NonNullable<typeof cfg.agent>;
if (imageSorted.length > 0) {
agent.imageModelFallbacks = selectedImages;
}
return { return {
...cfg, ...cfg,
agent, agent,
}; };
}); });
const allowlist = buildAllowlistSet(updated);
const allowlistMissing =
allowlist.size > 0 ? selected.filter((entry) => !allowlist.has(entry)) : [];
const allowlistMissingImages =
allowlist.size > 0
? selectedImages.filter((entry) => !allowlist.has(entry))
: [];
if (opts.json) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
@ -301,21 +312,7 @@ export async function modelsScanCommand(
setDefault: Boolean(opts.setDefault), setDefault: Boolean(opts.setDefault),
setImage: Boolean(opts.setImage), setImage: Boolean(opts.setImage),
results, results,
warnings: warnings: [],
allowlistMissing.length > 0 || allowlistMissingImages.length > 0
? [
...(allowlistMissing.length > 0
? [
`Selected models not in agent.allowedModels: ${allowlistMissing.join(", ")}`,
]
: []),
...(allowlistMissingImages.length > 0
? [
`Selected image models not in agent.allowedModels: ${allowlistMissingImages.join(", ")}`,
]
: []),
]
: [],
}, },
null, null,
2, 2,
@ -324,21 +321,6 @@ export async function modelsScanCommand(
return; return;
} }
if (allowlistMissing.length > 0) {
runtime.log(
warn(
`Warning: ${allowlistMissing.length} selected models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissing.join(", ")}`,
),
);
}
if (allowlistMissingImages.length > 0) {
runtime.log(
warn(
`Warning: ${allowlistMissingImages.length} selected image models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissingImages.join(", ")}`,
),
);
}
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${selected.join(", ")}`); runtime.log(`Fallbacks: ${selected.join(", ")}`);
if (selectedImages.length > 0) { if (selectedImages.length > 0) {

View File

@ -1,11 +1,6 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import { resolveModelTarget, updateConfig } from "./shared.js";
buildAllowlistSet,
modelKey,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsSetImageCommand( export async function modelsSetImageCommand(
modelRaw: string, modelRaw: string,
@ -13,22 +8,25 @@ export async function modelsSetImageCommand(
) { ) {
const updated = await updateConfig((cfg) => { const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const allowlist = buildAllowlistSet(cfg); const key = `${resolved.provider}/${resolved.model}`;
if (allowlist.size > 0) { const nextModels = { ...cfg.agent?.models };
const key = modelKey(resolved.provider, resolved.model); if (!nextModels[key]) nextModels[key] = {};
if (!allowlist.has(key)) {
throw new Error(`Model ${key} is not in agent.allowedModels.`);
}
}
return { return {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
imageModel: `${resolved.provider}/${resolved.model}`, imageModel: {
...((cfg.agent?.imageModel as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
primary: key,
},
models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Image model: ${updated.agent?.imageModel ?? modelRaw}`); runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`);
} }

View File

@ -1,31 +1,29 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import { resolveModelTarget, updateConfig } from "./shared.js";
buildAllowlistSet,
modelKey,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => { const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const allowlist = buildAllowlistSet(cfg); const key = `${resolved.provider}/${resolved.model}`;
if (allowlist.size > 0) { const nextModels = { ...cfg.agent?.models };
const key = modelKey(resolved.provider, resolved.model); if (!nextModels[key]) nextModels[key] = {};
if (!allowlist.has(key)) {
throw new Error(`Model ${key} is not in agent.allowedModels.`);
}
}
return { return {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
model: `${resolved.provider}/${resolved.model}`, model: {
...((cfg.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
primary: key,
},
models: nextModels,
}, },
}; };
}); });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Default model: ${updated.agent?.model ?? modelRaw}`); runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`);
} }

View File

@ -69,7 +69,8 @@ export function resolveModelTarget(params: {
export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> { export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> {
const allowed = new Set<string>(); const allowed = new Set<string>();
for (const raw of cfg.agent?.allowedModels ?? []) { const models = cfg.agent?.models ?? {};
for (const raw of Object.keys(models)) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue; if (!parsed) continue;
allowed.add(modelKey(parsed.provider, parsed.model)); allowed.add(modelKey(parsed.provider, parsed.model));

View File

@ -5,11 +5,12 @@ import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { resolveOAuthPath } from "../config/paths.js";
import { writeOAuthCredentials } from "./onboard-auth.js"; import { writeOAuthCredentials } from "./onboard-auth.js";
describe("writeOAuthCredentials", () => { describe("writeOAuthCredentials", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tempStateDir: string | null = null; let tempStateDir: string | null = null;
afterEach(async () => { afterEach(async () => {
@ -22,12 +23,24 @@ describe("writeOAuthCredentials", () => {
} else { } else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir; process.env.CLAWDBOT_STATE_DIR = previousStateDir;
} }
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
delete process.env.CLAWDBOT_OAUTH_DIR; delete process.env.CLAWDBOT_OAUTH_DIR;
}); });
it("writes oauth.json under CLAWDBOT_STATE_DIR/credentials", async () => { it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agent", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir; process.env.CLAWDBOT_STATE_DIR = tempStateDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const creds = { const creds = {
refresh: "refresh-token", refresh: "refresh-token",
@ -37,16 +50,19 @@ describe("writeOAuthCredentials", () => {
await writeOAuthCredentials("anthropic", creds); await writeOAuthCredentials("anthropic", creds);
const oauthPath = resolveOAuthPath(); const authProfilePath = path.join(
expect(oauthPath).toBe( tempStateDir,
path.join(tempStateDir, "credentials", "oauth.json"), "agent",
"auth-profiles.json",
); );
const raw = await fs.readFile(authProfilePath, "utf8");
const raw = await fs.readFile(oauthPath, "utf8"); const parsed = JSON.parse(raw) as {
const parsed = JSON.parse(raw) as Record<string, OAuthCredentials>; profiles?: Record<string, OAuthCredentials & { type?: string }>;
expect(parsed.anthropic).toMatchObject({ };
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
refresh: "refresh-token", refresh: "refresh-token",
access: "access-token", access: "access-token",
type: "oauth",
}); });
}); });
}); });

View File

@ -1,47 +1,73 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js";
export async function writeOAuthCredentials( export async function writeOAuthCredentials(
provider: OAuthProvider, provider: OAuthProvider,
creds: OAuthCredentials, creds: OAuthCredentials,
): Promise<void> { ): Promise<void> {
const filePath = resolveOAuthPath(); upsertAuthProfile({
const dir = path.dirname(filePath); profileId: `${provider}:default`,
await fs.mkdir(dir, { recursive: true, mode: 0o700 }); credential: {
let storage: Record<string, OAuthCredentials> = {}; type: "oauth",
try { provider,
const raw = await fs.readFile(filePath, "utf8"); ...creds,
const parsed = JSON.parse(raw) as Record<string, OAuthCredentials>; },
if (parsed && typeof parsed === "object") storage = parsed; });
} catch {
// ignore
}
storage[provider] = creds;
await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8");
await fs.chmod(filePath, 0o600);
} }
export async function setAnthropicApiKey(key: string) { export async function setAnthropicApiKey(key: string) {
const agentDir = resolveClawdbotAgentDir(); upsertAuthProfile({
const authStorage = discoverAuthStorage(agentDir); profileId: "anthropic:default",
authStorage.set("anthropic", { type: "api_key", key }); credential: {
type: "api_key",
provider: "anthropic",
key,
},
});
}
export function applyAuthProfileConfig(
cfg: ClawdbotConfig,
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth";
email?: string;
},
): ClawdbotConfig {
const profiles = {
...cfg.auth?.profiles,
[params.profileId]: {
provider: params.provider,
mode: params.mode,
...(params.email ? { email: params.email } : {}),
},
};
const order = { ...cfg.auth?.order };
const list = order[params.provider] ? [...order[params.provider]] : [];
if (!list.includes(params.profileId)) list.push(params.profileId);
order[params.provider] = list;
return {
...cfg,
auth: {
...cfg.auth,
profiles,
order,
},
};
} }
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const allowed = new Set(cfg.agent?.allowedModels ?? []); const models = { ...cfg.agent?.models };
allowed.add("anthropic/claude-opus-4-5"); models["anthropic/claude-opus-4-5"] = {
allowed.add("lmstudio/minimax-m2.1-gs32"); ...models["anthropic/claude-opus-4-5"],
alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus",
const aliases = { ...cfg.agent?.modelAliases }; };
if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; models["lmstudio/minimax-m2.1-gs32"] = {
if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; ...models["lmstudio/minimax-m2.1-gs32"],
alias: models["lmstudio/minimax-m2.1-gs32"]?.alias ?? "Minimax",
};
const providers = { ...cfg.models?.providers }; const providers = { ...cfg.models?.providers };
if (!providers.lmstudio) { if (!providers.lmstudio) {
@ -67,9 +93,12 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
...cfg, ...cfg,
agent: { agent: {
...cfg.agent, ...cfg.agent,
model: "Minimax", model: {
allowedModels: Array.from(allowed), ...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ??
modelAliases: aliases, {}),
primary: "lmstudio/minimax-m2.1-gs32",
},
models,
}, },
models: { models: {
mode: cfg.models?.mode ?? "merge", mode: cfg.models?.mode ?? "merge",

View File

@ -33,7 +33,13 @@ export function summarizeExistingConfig(config: ClawdbotConfig): string {
const rows: string[] = []; const rows: string[] = [];
if (config.agent?.workspace) if (config.agent?.workspace)
rows.push(`workspace: ${config.agent.workspace}`); rows.push(`workspace: ${config.agent.workspace}`);
if (config.agent?.model) rows.push(`model: ${config.agent.model}`); if (config.agent?.model) {
const model =
typeof config.agent.model === "string"
? config.agent.model
: config.agent.model.primary;
if (model) rows.push(`model: ${model}`);
}
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
if (typeof config.gateway?.port === "number") { if (typeof config.gateway?.port === "number") {
rows.push(`gateway.port: ${config.gateway.port}`); rows.push(`gateway.port: ${config.gateway.port}`);

View File

@ -14,7 +14,11 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import { healthCommand } from "./health.js"; import { healthCommand } from "./health.js";
import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; import {
applyAuthProfileConfig,
applyMinimaxConfig,
setAnthropicApiKey,
} from "./onboard-auth.js";
import { import {
applyWizardMetadata, applyWizardMetadata,
DEFAULT_WORKSPACE, DEFAULT_WORKSPACE,
@ -98,6 +102,11 @@ export async function runNonInteractiveOnboarding(
return; return;
} }
await setAnthropicApiKey(key); await setAnthropicApiKey(key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "minimax") { } else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig); nextConfig = applyMinimaxConfig(nextConfig);
} else if ( } else if (

View File

@ -12,7 +12,11 @@ vi.mock("../config/config.js", async (importOriginal) => {
return { return {
...actual, ...actual,
loadConfig: () => ({ loadConfig: () => ({
agent: { model: "pi:opus", contextTokens: 32000 }, agent: {
model: { primary: "pi:opus" },
models: { "pi:opus": {} },
contextTokens: 32000,
},
}), }),
}; };
}); });

View File

@ -8,7 +8,7 @@ import {
ensureAgentWorkspace, ensureAgentWorkspace,
} from "../agents/workspace.js"; } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import { applyModelAliasDefaults } from "../config/defaults.js"; import { applyModelDefaults } from "../config/defaults.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@ -31,7 +31,7 @@ async function readConfigFileRaw(): Promise<{
async function writeConfigFile(cfg: ClawdbotConfig) { async function writeConfigFile(cfg: ClawdbotConfig) {
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true }); await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
.trimEnd() .trimEnd()
.concat("\n"); .concat("\n");
await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8"); await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8");

View File

@ -628,6 +628,18 @@ describe("legacy config detection", () => {
} }
}); });
it("rejects legacy agent.model string", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agent: { model: "anthropic/claude-opus-4-5" },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agent.model");
}
});
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => { it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
@ -641,6 +653,38 @@ describe("legacy config detection", () => {
expect(res.config?.telegram?.requireMention).toBeUndefined(); expect(res.config?.telegram?.requireMention).toBeUndefined();
}); });
it("migrates legacy model config to agent.models + model lists", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
agent: {
model: "anthropic/claude-opus-4-5",
modelFallbacks: ["openai/gpt-4.1-mini"],
imageModel: "openai/gpt-4.1-mini",
imageModelFallbacks: ["anthropic/claude-opus-4-5"],
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
modelAliases: { Opus: "anthropic/claude-opus-4-5" },
},
});
expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5");
expect(res.config?.agent?.model?.fallbacks).toEqual([
"openai/gpt-4.1-mini",
]);
expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini");
expect(res.config?.agent?.imageModel?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
expect(
res.config?.agent?.models?.["anthropic/claude-opus-4-5"],
).toMatchObject({ alias: "Opus" });
expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
expect(res.config?.agent?.allowedModels).toBeUndefined();
expect(res.config?.agent?.modelAliases).toBeUndefined();
expect(res.config?.agent?.modelFallbacks).toBeUndefined();
expect(res.config?.agent?.imageModelFallbacks).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => { it("surfaces legacy issues in snapshot", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");

View File

@ -92,43 +92,23 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
}; };
} }
function normalizeAliasKey(value: string): string { export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
return value.trim().toLowerCase();
}
export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
const existingAgent = cfg.agent; const existingAgent = cfg.agent;
if (!existingAgent) return cfg; if (!existingAgent) return cfg;
const existingAliases = existingAgent?.modelAliases ?? {}; const existingModels = existingAgent.models ?? {};
if (Object.keys(existingModels).length === 0) return cfg;
const byNormalized = new Map<string, string>();
for (const key of Object.keys(existingAliases)) {
const norm = normalizeAliasKey(key);
if (!norm) continue;
if (!byNormalized.has(norm)) byNormalized.set(norm, key);
}
let mutated = false; let mutated = false;
const nextAliases: Record<string, string> = { ...existingAliases }; const nextModels: Record<string, { alias?: string }> = {
...existingModels,
};
for (const [canonicalKey, target] of Object.entries(DEFAULT_MODEL_ALIASES)) { for (const [alias, target] of Object.entries(DEFAULT_MODEL_ALIASES)) {
const norm = normalizeAliasKey(canonicalKey); const entry = nextModels[target];
const existingKey = byNormalized.get(norm); if (!entry) continue;
if (entry.alias !== undefined) continue;
if (!existingKey) { nextModels[target] = { ...entry, alias };
nextAliases[canonicalKey] = target; mutated = true;
byNormalized.set(norm, canonicalKey);
mutated = true;
continue;
}
const existingValue = String(existingAliases[existingKey] ?? "");
if (existingKey !== canonicalKey && existingValue === target) {
delete nextAliases[existingKey];
nextAliases[canonicalKey] = target;
byNormalized.set(norm, canonicalKey);
mutated = true;
}
} }
if (!mutated) return cfg; if (!mutated) return cfg;
@ -137,7 +117,7 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
...cfg, ...cfg,
agent: { agent: {
...existingAgent, ...existingAgent,
modelAliases: nextAliases, models: nextModels,
}, },
}; };
} }

View File

@ -11,7 +11,7 @@ import {
import { import {
applyIdentityDefaults, applyIdentityDefaults,
applyLoggingDefaults, applyLoggingDefaults,
applyModelAliasDefaults, applyModelDefaults,
applySessionDefaults, applySessionDefaults,
applyTalkApiKey, applyTalkApiKey,
} from "./defaults.js"; } from "./defaults.js";
@ -114,7 +114,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
} }
return {}; return {};
} }
const cfg = applyModelAliasDefaults( const cfg = applyModelDefaults(
applySessionDefaults( applySessionDefaults(
applyLoggingDefaults( applyLoggingDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig), applyIdentityDefaults(validated.data as ClawdbotConfig),
@ -148,7 +148,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const exists = deps.fs.existsSync(configPath); const exists = deps.fs.existsSync(configPath);
if (!exists) { if (!exists) {
const config = applyTalkApiKey( const config = applyTalkApiKey(
applyModelAliasDefaults(applySessionDefaults({})), applyModelDefaults(applySessionDefaults({})),
); );
const legacyIssues: LegacyConfigIssue[] = []; const legacyIssues: LegacyConfigIssue[] = [];
return { return {
@ -204,7 +204,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: parsedRes.parsed, parsed: parsedRes.parsed,
valid: true, valid: true,
config: applyTalkApiKey( config: applyTalkApiKey(
applyModelAliasDefaults( applyModelDefaults(
applySessionDefaults(applyLoggingDefaults(validated.config)), applySessionDefaults(applyLoggingDefaults(validated.config)),
), ),
), ),
@ -229,7 +229,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
await deps.fs.promises.mkdir(path.dirname(configPath), { await deps.fs.promises.mkdir(path.dirname(configPath), {
recursive: true, recursive: true,
}); });
const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
.trimEnd() .trimEnd()
.concat("\n"); .concat("\n");
await deps.fs.promises.writeFile(configPath, json, "utf-8"); await deps.fs.promises.writeFile(configPath, json, "utf-8");

View File

@ -3,6 +3,7 @@ import type { LegacyConfigIssue } from "./types.js";
type LegacyConfigRule = { type LegacyConfigRule = {
path: string[]; path: string[];
message: string; message: string;
match?: (value: unknown, root: Record<string, unknown>) => boolean;
}; };
type LegacyConfigMigration = { type LegacyConfigMigration = {
@ -27,6 +28,38 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
message: message:
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).', 'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
}, },
{
path: ["agent", "model"],
message:
"agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "imageModel"],
message:
"agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
match: (value) => typeof value === "string",
},
{
path: ["agent", "allowedModels"],
message:
"agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelAliases"],
message:
"agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "modelFallbacks"],
message:
"agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).",
},
{
path: ["agent", "imageModelFallbacks"],
message:
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
},
]; ];
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
@ -165,6 +198,158 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
} }
}, },
}, },
{
id: "agent.model-config-v2",
describe:
"Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
apply: (raw, changes) => {
const agent =
raw.agent && typeof raw.agent === "object"
? (raw.agent as Record<string, unknown>)
: null;
if (!agent) return;
const legacyModel =
typeof agent.model === "string" ? String(agent.model) : undefined;
const legacyImageModel =
typeof agent.imageModel === "string"
? String(agent.imageModel)
: undefined;
const legacyAllowed = Array.isArray(agent.allowedModels)
? (agent.allowedModels as unknown[]).map(String)
: [];
const legacyModelFallbacks = Array.isArray(agent.modelFallbacks)
? (agent.modelFallbacks as unknown[]).map(String)
: [];
const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks)
? (agent.imageModelFallbacks as unknown[]).map(String)
: [];
const legacyAliases =
agent.modelAliases && typeof agent.modelAliases === "object"
? (agent.modelAliases as Record<string, unknown>)
: {};
const hasLegacy =
legacyModel ||
legacyImageModel ||
legacyAllowed.length > 0 ||
legacyModelFallbacks.length > 0 ||
legacyImageModelFallbacks.length > 0 ||
Object.keys(legacyAliases).length > 0;
if (!hasLegacy) return;
const models =
agent.models && typeof agent.models === "object"
? (agent.models as Record<string, unknown>)
: {};
const ensureModel = (rawKey?: string) => {
const key = String(rawKey ?? "").trim();
if (!key) return;
if (!models[key]) models[key] = {};
};
ensureModel(legacyModel);
ensureModel(legacyImageModel);
for (const key of legacyAllowed) ensureModel(key);
for (const key of legacyModelFallbacks) ensureModel(key);
for (const key of legacyImageModelFallbacks) ensureModel(key);
for (const target of Object.values(legacyAliases)) {
ensureModel(String(target ?? ""));
}
for (const [alias, targetRaw] of Object.entries(legacyAliases)) {
const target = String(targetRaw ?? "").trim();
if (!target) continue;
const entry =
models[target] && typeof models[target] === "object"
? (models[target] as Record<string, unknown>)
: {};
if (!("alias" in entry)) {
entry.alias = alias;
models[target] = entry;
}
}
const currentModel =
agent.model && typeof agent.model === "object"
? (agent.model as Record<string, unknown>)
: null;
if (currentModel) {
if (!currentModel.primary && legacyModel) {
currentModel.primary = legacyModel;
}
if (
legacyModelFallbacks.length > 0 &&
(!Array.isArray(currentModel.fallbacks) ||
currentModel.fallbacks.length === 0)
) {
currentModel.fallbacks = legacyModelFallbacks;
}
agent.model = currentModel;
} else if (legacyModel || legacyModelFallbacks.length > 0) {
agent.model = {
primary: legacyModel,
fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [],
};
}
const currentImageModel =
agent.imageModel && typeof agent.imageModel === "object"
? (agent.imageModel as Record<string, unknown>)
: null;
if (currentImageModel) {
if (!currentImageModel.primary && legacyImageModel) {
currentImageModel.primary = legacyImageModel;
}
if (
legacyImageModelFallbacks.length > 0 &&
(!Array.isArray(currentImageModel.fallbacks) ||
currentImageModel.fallbacks.length === 0)
) {
currentImageModel.fallbacks = legacyImageModelFallbacks;
}
agent.imageModel = currentImageModel;
} else if (legacyImageModel || legacyImageModelFallbacks.length > 0) {
agent.imageModel = {
primary: legacyImageModel,
fallbacks: legacyImageModelFallbacks.length
? legacyImageModelFallbacks
: [],
};
}
agent.models = models;
if (legacyModel !== undefined) {
changes.push("Migrated agent.model string → agent.model.primary.");
}
if (legacyModelFallbacks.length > 0) {
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
}
if (legacyImageModel !== undefined) {
changes.push(
"Migrated agent.imageModel string → agent.imageModel.primary.",
);
}
if (legacyImageModelFallbacks.length > 0) {
changes.push(
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
);
}
if (legacyAllowed.length > 0) {
changes.push("Migrated agent.allowedModels → agent.models.");
}
if (Object.keys(legacyAliases).length > 0) {
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
}
delete agent.allowedModels;
delete agent.modelAliases;
delete agent.modelFallbacks;
delete agent.imageModelFallbacks;
},
},
]; ];
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
@ -180,7 +365,7 @@ export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
} }
cursor = (cursor as Record<string, unknown>)[key]; cursor = (cursor as Record<string, unknown>)[key];
} }
if (cursor !== undefined) { if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) {
issues.push({ path: rule.path.join("."), message: rule.message }); issues.push({ path: rule.path.join("."), message: rule.message });
} }
} }

View File

@ -1,90 +1,56 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyLoggingDefaults, applyModelAliasDefaults } from "./defaults.js"; import { applyModelDefaults } from "./defaults.js";
import type { ClawdbotConfig } from "./types.js"; import type { ClawdbotConfig } from "./types.js";
describe("applyModelAliasDefaults", () => { describe("applyModelDefaults", () => {
it("adds default shorthands", () => { it("adds default aliases when models are present", () => {
const cfg = { agent: {} } satisfies ClawdbotConfig; const cfg = {
const next = applyModelAliasDefaults(cfg); agent: {
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-5.2": {},
},
},
} satisfies ClawdbotConfig;
const next = applyModelDefaults(cfg);
expect(next.agent?.modelAliases).toEqual({ expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
opus: "anthropic/claude-opus-4-5", "opus",
sonnet: "anthropic/claude-sonnet-4-5", );
gpt: "openai/gpt-5.2", expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
"gpt-mini": "openai/gpt-5-mini",
gemini: "google/gemini-3-pro-preview",
"gemini-flash": "google/gemini-3-flash-preview",
});
}); });
it("normalizes casing when alias matches the default target", () => { it("does not override existing aliases", () => {
const cfg = { const cfg = {
agent: { modelAliases: { Opus: "anthropic/claude-opus-4-5" } }, agent: {
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
} satisfies ClawdbotConfig; } satisfies ClawdbotConfig;
const next = applyModelAliasDefaults(cfg); const next = applyModelDefaults(cfg);
expect(next.agent?.modelAliases).toMatchObject({ expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
opus: "anthropic/claude-opus-4-5", "Opus",
}); );
expect(next.agent?.modelAliases).not.toHaveProperty("Opus");
}); });
it("does not override existing alias values", () => { it("respects explicit empty alias disables", () => {
const cfg = { const cfg = {
agent: { modelAliases: { gpt: "openai/gpt-4.1" } }, agent: {
models: {
"google/gemini-3-pro-preview": { alias: "" },
"google/gemini-3-flash-preview": {},
},
},
} satisfies ClawdbotConfig; } satisfies ClawdbotConfig;
const next = applyModelAliasDefaults(cfg); const next = applyModelDefaults(cfg);
expect(next.agent?.modelAliases?.gpt).toBe("openai/gpt-4.1"); expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
expect(next.agent?.modelAliases).toMatchObject({ expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
"gpt-mini": "openai/gpt-5-mini",
opus: "anthropic/claude-opus-4-5",
sonnet: "anthropic/claude-sonnet-4-5",
gemini: "google/gemini-3-pro-preview",
"gemini-flash": "google/gemini-3-flash-preview",
});
});
it("does not rename when casing differs and value differs", () => {
const cfg = {
agent: { modelAliases: { GPT: "openai/gpt-4.1-mini" } },
} satisfies ClawdbotConfig;
const next = applyModelAliasDefaults(cfg);
expect(next.agent?.modelAliases).toMatchObject({
GPT: "openai/gpt-4.1-mini",
});
expect(next.agent?.modelAliases).not.toHaveProperty("gpt");
});
it("respects explicit empty-string disables", () => {
const cfg = {
agent: { modelAliases: { gemini: "" } },
} satisfies ClawdbotConfig;
const next = applyModelAliasDefaults(cfg);
expect(next.agent?.modelAliases?.gemini).toBe("");
expect(next.agent?.modelAliases).toHaveProperty(
"gemini-flash", "gemini-flash",
"google/gemini-3-flash-preview",
); );
}); });
}); });
describe("applyLoggingDefaults", () => {
it("defaults redactSensitive to tools", () => {
const result = applyLoggingDefaults({ logging: {} });
expect(result.logging?.redactSensitive).toBe("tools");
});
it("preserves explicit redactSensitive", () => {
const result = applyLoggingDefaults({
logging: { redactSensitive: "off" },
});
expect(result.logging?.redactSensitive).toBe("off");
});
});

View File

@ -1,6 +1,5 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { resolveUserPath } from "../utils.js";
import type { ClawdbotConfig } from "./types.js"; import type { ClawdbotConfig } from "./types.js";
/** /**
@ -33,6 +32,15 @@ export function resolveStateDir(
return path.join(homedir(), ".clawdbot"); return path.join(homedir(), ".clawdbot");
} }
function resolveUserPath(input: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
return path.resolve(trimmed.replace("~", os.homedir()));
}
return path.resolve(trimmed);
}
export const STATE_DIR_CLAWDBOT = resolveStateDir(); export const STATE_DIR_CLAWDBOT = resolveStateDir();
/** /**

View File

@ -87,10 +87,13 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.reload.mode": "Config Reload Mode", "gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)", "gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"agent.workspace": "Workspace", "agent.workspace": "Workspace",
"agent.model": "Default Model", "auth.profiles": "Auth Profiles",
"agent.imageModel": "Image Model", "auth.order": "Auth Profile Order",
"agent.modelFallbacks": "Model Fallbacks", "agent.models": "Models",
"agent.imageModelFallbacks": "Image Model Fallbacks", "agent.model.primary": "Primary Model",
"agent.model.fallbacks": "Model Fallbacks",
"agent.imageModel.primary": "Image Model",
"agent.imageModel.fallbacks": "Image Model Fallbacks",
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL", "browser.controlUrl": "Browser Control URL",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
@ -114,12 +117,18 @@ const FIELD_HELP: Record<string, string> = {
'Hot reload strategy for config changes ("hybrid" recommended).', 'Hot reload strategy for config changes ("hybrid" recommended).',
"gateway.reload.debounceMs": "gateway.reload.debounceMs":
"Debounce window (ms) before applying config changes.", "Debounce window (ms) before applying config changes.",
"agent.modelFallbacks": "auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order":
"Ordered auth profile IDs per provider (used for automatic failover).",
"agent.models":
"Configured model catalog (keys are full provider/model IDs).",
"agent.model.primary": "Primary model (provider/model).",
"agent.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.", "Ordered fallback models (provider/model). Used when the primary model fails.",
"agent.imageModel": "agent.imageModel.primary":
"Optional image-capable model (provider/model) used by the image tool.", "Optional image model (provider/model) used when the primary model lacks image input.",
"agent.imageModelFallbacks": "agent.imageModel.fallbacks":
"Ordered fallback image models (provider/model) used by the image tool.", "Ordered fallback image models (provider/model).",
"session.agentToAgent.maxPingPongTurns": "session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (05).", "Max reply-back turns between requester and target (05).",
}; };

View File

@ -34,6 +34,7 @@ export type SessionEntry = {
elevatedLevel?: string; elevatedLevel?: string;
providerOverride?: string; providerOverride?: string;
modelOverride?: string; modelOverride?: string;
authProfileOverride?: string;
groupActivation?: "mention" | "always"; groupActivation?: "mention" | "always";
groupActivationNeedsSystemIntro?: boolean; groupActivationNeedsSystemIntro?: boolean;
sendPolicy?: "allow" | "deny"; sendPolicy?: "allow" | "deny";

View File

@ -639,7 +639,28 @@ export type ModelsConfig = {
providers?: Record<string, ModelProviderConfig>; providers?: Record<string, ModelProviderConfig>;
}; };
export type AuthProfileConfig = {
provider: string;
mode: "api_key" | "oauth";
email?: string;
};
export type AuthConfig = {
profiles?: Record<string, AuthProfileConfig>;
order?: Record<string, string[]>;
};
export type AgentModelEntryConfig = {
alias?: string;
};
export type AgentModelListConfig = {
primary?: string;
fallbacks?: string[];
};
export type ClawdbotConfig = { export type ClawdbotConfig = {
auth?: AuthConfig;
env?: { env?: {
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */
shellEnv?: { shellEnv?: {
@ -669,22 +690,16 @@ export type ClawdbotConfig = {
skills?: SkillsConfig; skills?: SkillsConfig;
models?: ModelsConfig; models?: ModelsConfig;
agent?: { agent?: {
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ /** Primary model and fallbacks (provider/model). */
model?: string; model?: AgentModelListConfig;
/** Optional image-capable model (provider/model) used by the image tool. */ /** Optional image-capable model and fallbacks (provider/model). */
imageModel?: string; imageModel?: AgentModelListConfig;
/** Model catalog with optional aliases (full provider/model keys). */
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */ /** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string; workspace?: string;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string; userTimezone?: string;
/** Optional allowlist for /model (provider/model or model-only). */
allowedModels?: string[];
/** Optional model aliases for /model (alias -> provider/model). */
modelAliases?: Record<string, string>;
/** Ordered fallback models (provider/model). */
modelFallbacks?: string[];
/** Ordered fallback image models (provider/model) for the image tool. */
imageModelFallbacks?: string[];
/** Optional display-only context window override (used for % in status UIs). */ /** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number; contextTokens?: number;
/** Default thinking level when no /think directive is present. */ /** Default thinking level when no /think directive is present. */

View File

@ -1,6 +1,6 @@
import { import {
applyIdentityDefaults, applyIdentityDefaults,
applyModelAliasDefaults, applyModelDefaults,
applySessionDefaults, applySessionDefaults,
} from "./defaults.js"; } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js"; import { findLegacyConfigIssues } from "./legacy.js";
@ -34,7 +34,7 @@ export function validateConfigObject(
} }
return { return {
ok: true, ok: true,
config: applyModelAliasDefaults( config: applyModelDefaults(
applySessionDefaults( applySessionDefaults(
applyIdentityDefaults(validated.data as ClawdbotConfig), applyIdentityDefaults(validated.data as ClawdbotConfig),
), ),

View File

@ -373,17 +373,46 @@ export const ClawdbotSchema = z.object({
seamColor: HexColorSchema.optional(), seamColor: HexColorSchema.optional(),
}) })
.optional(), .optional(),
auth: z
.object({
profiles: z
.record(
z.string(),
z.object({
provider: z.string(),
mode: z.union([z.literal("api_key"), z.literal("oauth")]),
email: z.string().optional(),
}),
)
.optional(),
order: z.record(z.string(), z.array(z.string())).optional(),
})
.optional(),
models: ModelsConfigSchema, models: ModelsConfigSchema,
agent: z agent: z
.object({ .object({
model: z.string().optional(), model: z
imageModel: z.string().optional(), .object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
imageModel: z
.object({
primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(),
})
.optional(),
models: z
.record(
z.string(),
z.object({
alias: z.string().optional(),
}),
)
.optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
userTimezone: z.string().optional(), userTimezone: z.string().optional(),
allowedModels: z.array(z.string()).optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
modelFallbacks: z.array(z.string()).optional(),
imageModelFallbacks: z.array(z.string()).optional(),
contextTokens: z.number().int().positive().optional(), contextTokens: z.number().int().positive().optional(),
tools: z tools: z
.object({ .object({

View File

@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
let lastAppliedKeys: string[] = [];
function isTruthy(raw: string | undefined): boolean { function isTruthy(raw: string | undefined): boolean {
if (!raw) return false; if (!raw) return false;
@ -34,13 +35,16 @@ export function loadShellEnvFallback(
const logger = opts.logger ?? console; const logger = opts.logger ?? console;
const exec = opts.exec ?? execFileSync; const exec = opts.exec ?? execFileSync;
if (!opts.enabled) if (!opts.enabled) {
lastAppliedKeys = [];
return { ok: true, applied: [], skippedReason: "disabled" }; return { ok: true, applied: [], skippedReason: "disabled" };
}
const hasAnyKey = opts.expectedKeys.some((key) => const hasAnyKey = opts.expectedKeys.some((key) =>
Boolean(opts.env[key]?.trim()), Boolean(opts.env[key]?.trim()),
); );
if (hasAnyKey) { if (hasAnyKey) {
lastAppliedKeys = [];
return { ok: true, applied: [], skippedReason: "already-has-keys" }; return { ok: true, applied: [], skippedReason: "already-has-keys" };
} }
@ -63,6 +67,7 @@ export function loadShellEnvFallback(
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[clawdbot] shell env fallback failed: ${msg}`); logger.warn(`[clawdbot] shell env fallback failed: ${msg}`);
lastAppliedKeys = [];
return { ok: false, error: msg, applied: [] }; return { ok: false, error: msg, applied: [] };
} }
@ -87,6 +92,7 @@ export function loadShellEnvFallback(
applied.push(key); applied.push(key);
} }
lastAppliedKeys = applied;
return { ok: true, applied }; return { ok: true, applied };
} }
@ -103,3 +109,7 @@ export function resolveShellEnvFallbackTimeoutMs(
if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS; if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS;
return Math.max(0, parsed); return Math.max(0, parsed);
} }
export function getShellEnvAppliedKeys(): string[] {
return [...lastAppliedKeys];
}

View File

@ -2,17 +2,17 @@ import path from "node:path";
import { import {
loginAnthropic, loginAnthropic,
loginOpenAICodex,
type OAuthCredentials, type OAuthCredentials,
type OAuthProvider, type OAuthProvider,
} from "@mariozechner/pi-ai"; } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import { import {
isRemoteEnvironment, isRemoteEnvironment,
loginAntigravityVpsAware, loginAntigravityVpsAware,
} from "../commands/antigravity-oauth.js"; } from "../commands/antigravity-oauth.js";
import { healthCommand } from "../commands/health.js"; import { healthCommand } from "../commands/health.js";
import { import {
applyAuthProfileConfig,
applyMinimaxConfig, applyMinimaxConfig,
setAnthropicApiKey, setAnthropicApiKey,
writeOAuthCredentials, writeOAuthCredentials,
@ -227,6 +227,11 @@ export async function runOnboardingWizard(
spin.stop("OAuth complete"); spin.stop("OAuth complete");
if (oauthCreds) { if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds); await writeOAuthCredentials("anthropic", oauthCreds);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "oauth",
});
} }
} catch (err) { } catch (err) {
spin.stop("OAuth failed"); spin.stop("OAuth failed");
@ -250,10 +255,7 @@ export async function runOnboardingWizard(
); );
const spin = prompter.progress("Starting OAuth flow…"); const spin = prompter.progress("Starting OAuth flow…");
try { try {
const agentDir = resolveClawdbotAgentDir(); const creds = await loginOpenAICodex({
const authStorage = discoverAuthStorage(agentDir);
const provider = "openai-codex" as unknown as OAuthProvider;
await authStorage.login(provider, {
onAuth: async ({ url }) => { onAuth: async ({ url }) => {
if (isRemote) { if (isRemote) {
spin.stop("OAuth URL ready"); spin.stop("OAuth URL ready");
@ -275,6 +277,17 @@ export async function runOnboardingWizard(
onProgress: (msg) => spin.update(msg), onProgress: (msg) => spin.update(msg),
}); });
spin.stop("OpenAI OAuth complete"); spin.stop("OpenAI OAuth complete");
if (creds) {
await writeOAuthCredentials(
"openai-codex" as unknown as OAuthProvider,
creds,
);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "openai-codex:default",
provider: "openai-codex",
mode: "oauth",
});
}
} catch (err) { } catch (err) {
spin.stop("OpenAI OAuth failed"); spin.stop("OpenAI OAuth failed");
runtime.error(String(err)); runtime.error(String(err));
@ -314,11 +327,29 @@ export async function runOnboardingWizard(
spin.stop("Antigravity OAuth complete"); spin.stop("Antigravity OAuth complete");
if (oauthCreds) { if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds); await writeOAuthCredentials("google-antigravity", oauthCreds);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "google-antigravity:default",
provider: "google-antigravity",
mode: "oauth",
});
nextConfig = { nextConfig = {
...nextConfig, ...nextConfig,
agent: { agent: {
...nextConfig.agent, ...nextConfig.agent,
model: "google-antigravity/claude-opus-4-5-thinking", model: {
...((nextConfig.agent?.model as {
primary?: string;
fallbacks?: string[];
}) ?? {}),
primary: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...nextConfig.agent?.models,
"google-antigravity/claude-opus-4-5-thinking":
nextConfig.agent?.models?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
},
}, },
}; };
await prompter.note( await prompter.note(
@ -336,6 +367,11 @@ export async function runOnboardingWizard(
validate: (value) => (value?.trim() ? undefined : "Required"), validate: (value) => (value?.trim() ? undefined : "Required"),
}); });
await setAnthropicApiKey(String(key).trim()); await setAnthropicApiKey(String(key).trim());
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "minimax") { } else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig); nextConfig = applyMinimaxConfig(nextConfig);
} }