diff --git a/CHANGELOG.md b/CHANGELOG.md index 07afb65df..3b3c82b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Breaking - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s 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 - 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: 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. -- 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` 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. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. diff --git a/docs/configuration.md b/docs/configuration.md index 1959ee0ad..70eabc4e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,18 +91,40 @@ Env var equivalent: ### 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 stores **API keys** in the agent auth store: -- `~/.clawdbot/agent/auth.json` +The embedded Pi agent maintains a runtime cache at: +- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually) 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) -On first use, Clawdbot imports `oauth.json` entries into `auth.json` so the embedded -agent can use them. `oauth.json` remains the source of truth for OAuth refresh. +On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. + +### `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` @@ -494,14 +516,12 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V ### `agent` Controls the embedded agent runtime (model/thinking/verbose/timeouts). -`allowedModels` lets `/model` list/filter and enforce a per-session allowlist -(omit to show the full catalog). -`modelAliases` adds short names for `/model` (alias -> provider/model). -`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. +`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`). +`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers. +`agent.imageModel` is optional and is **only used if the primary model lacks image input**. -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` - `sonnet` -> `anthropic/claude-sonnet-4-5` @@ -515,23 +535,24 @@ If you configure the same alias name (case-insensitive) yourself, your value win ```json5 { agent: { - model: "anthropic/claude-opus-4-5", - allowedModels: [ - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-1" - ], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", - Sonnet: "anthropic/claude-sonnet-4-1" + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, + "openrouter/deepseek/deepseek-r1:free": {} + }, + model: { + primary: "anthropic/claude-opus-4-5", + 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", verboseDefault: "off", elevatedDefault: "on", @@ -566,8 +587,8 @@ Block streaming: } ``` -`agent.model` 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`). +`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). +Aliases come from `agent.models.*.alias` (e.g. `Opus`). If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary deprecation fallback. Z.AI models are available as `zai/` (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) - 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 { - 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: { mode: "merge", providers: { @@ -766,14 +792,10 @@ via **LM Studio** using the **Responses API**. ```json5 { agent: { - model: "Minimax", - allowedModels: [ - "anthropic/claude-opus-4-5", - "lmstudio/minimax-m2.1-gs32" - ], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", - Minimax: "lmstudio/minimax-m2.1-gs32" + model: { primary: "lmstudio/minimax-m2.1-gs32" }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } } }, models: { diff --git a/docs/doctor.md b/docs/doctor.md index e07f15229..51292f5fa 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -27,8 +27,13 @@ Doctor will: - Show the migration it applied. - 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: - `routing.allowFrom` → `whatsapp.allowFrom` +- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` + → `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks` ## Usage diff --git a/docs/faq.md b/docs/faq.md index 956cf5e3c..d969916bc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -15,7 +15,8 @@ Everything lives under `~/.clawdbot/`: |------|---------| | `~/.clawdbot/clawdbot.json` | Main config (JSON5) | | `~/.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/sessions/` | Conversation history & state | | `~/.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): `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 { "agent": { - "model": "anthropic/claude-opus-4-5", - "allowedModels": [ - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-5", - "anthropic/claude-haiku-4-5" - ], - "modelAliases": { - "opus": "anthropic/claude-opus-4-5", - "sonnet": "anthropic/claude-sonnet-4-5", - "haiku": "anthropic/claude-haiku-4-5" + "model": { "primary": "anthropic/claude-opus-4-5" }, + "models": { + "anthropic/claude-opus-4-5": { "alias": "opus" }, + "anthropic/claude-sonnet-4-5": { "alias": "sonnet" }, + "anthropic/claude-haiku-4-5": { "alias": "haiku" } } } } @@ -606,7 +602,8 @@ If you don't want to use Anthropic directly, you can use alternative providers: ```json5 { 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-..." } } } @@ -616,7 +613,8 @@ If you don't want to use Anthropic directly, you can use alternative providers: ```json5 { agent: { - model: "zai/glm-4.7", + model: { primary: "zai/glm-4.7" }, + models: { "zai/glm-4.7": {} }, env: { ZAI_API_KEY: "..." } } } diff --git a/docs/models.md b/docs/models.md index 32f6d0863..cf88065a5 100644 --- a/docs/models.md +++ b/docs/models.md @@ -16,35 +16,32 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks. - default: configured models only - flags: `--all` (full catalog), `--local`, `--provider `, `--json`, `--plain` - `clawdbot models status` - - show default model + aliases + fallbacks + allowlist + - show default model + aliases + fallbacks + configured models - `clawdbot models set ` - - writes `agent.model` in config + - writes `agent.model.primary` and ensures `agent.models` entry - `clawdbot models set-image ` - - writes `agent.imageModel` in config + - writes `agent.imageModel.primary` and ensures `agent.models` entry - `clawdbot models aliases list|add|remove` - - writes `agent.modelAliases` + - writes `agent.models.*.alias` - `clawdbot models fallbacks list|add|remove|clear` - - writes `agent.modelFallbacks` + - writes `agent.model.fallbacks` - `clawdbot models image-fallbacks list|add|remove|clear` - - writes `agent.imageModelFallbacks` + - writes `agent.imageModel.fallbacks` - `clawdbot models scan` - OpenRouter :free scan; probe tool-call + image; interactive selection ## Config changes -- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs). -- Add `agent.imageModel?: string` (optional image-capable model for image tool). -- Add `agent.imageModelFallbacks?: string[]` (ordered list for image tool). -- Keep existing: - - `agent.model` (default) - - `agent.allowedModels` (list filter) - - `agent.modelAliases` (shortcut names) +- `agent.models` (configured model catalog + aliases). +- `agent.model.primary` + `agent.model.fallbacks`. +- `agent.imageModel.primary` + `agent.imageModel.fallbacks` (optional). +- `auth.profiles` + `auth.order` for per-provider auth failover. ## Scan behavior (models scan) Input - 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` - Probe controls: `--timeout`, `--concurrency` @@ -66,17 +63,20 @@ Interactive selection (TTY) - Non-TTY: auto-select; require `--yes`/`--no-input` to apply. Output -- Writes `agent.modelFallbacks` ordered. -- Writes `agent.imageModelFallbacks` ordered (image-capable models). -- Optional `--set-default` to set `agent.model`. -- Optional `--set-image` to set `agent.imageModel`. +- Writes `agent.model.fallbacks` ordered. +- Writes `agent.imageModel.fallbacks` ordered (image-capable models). +- Ensures `agent.models` entries exist for selected models. +- Optional `--set-default` to set `agent.model.primary`. +- Optional `--set-image` to set `agent.imageModel.primary`. ## Runtime fallback -- On model failure: try `agent.modelFallbacks` in order. -- Ignore fallback entries not in `agent.allowedModels` (if allowlist set). -- Persist last successful provider/model to session entry. -- `/status` shows last used model (not just default). +- On model failure: try `agent.model.fallbacks` in order. +- Per-provider auth failover uses `auth.order` (or stored profile order) **before** + moving to the next model. +- 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 @@ -86,5 +86,5 @@ Output ## 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. diff --git a/docs/onboarding.md b/docs/onboarding.md index 200fd267e..d1b1c1dd1 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -41,7 +41,7 @@ The macOS app should: - `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`) Why this location matters: it’s 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) @@ -148,7 +148,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored For now, remote onboarding should: - 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) ### Manual credential setup diff --git a/docs/proposals/model-config.md b/docs/proposals/model-config.md index 7de0d54d6..b7488378d 100644 --- a/docs/proposals/model-config.md +++ b/docs/proposals/model-config.md @@ -87,7 +87,7 @@ Model listing - alias - provider - 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) @@ -121,19 +121,20 @@ Support detection ## Migration (doctor + gateway auto-run) Inputs -- `agent.model` (string) -- `agent.modelFallbacks` (string[]) -- `agent.imageModel` (string) -- `agent.imageModelFallbacks` (string[]) -- `agent.allowedModels` (string[]) -- `agent.modelAliases` (record) +- Legacy keys (pre-migration): + - `agent.model` (string) + - `agent.modelFallbacks` (string[]) + - `agent.imageModel` (string) + - `agent.imageModelFallbacks` (string[]) + - `agent.allowedModels` (string[]) + - `agent.modelAliases` (record) Outputs - `agent.models` map with keys for all referenced models - `agent.model.primary/fallbacks` - `agent.imageModel.primary/fallbacks` -- `auth.profiles` seeded from current auth.json + env (as `provider:default`) -- `auth.order` seeded with `["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"]` when config is updated Auto-run - Gateway start detects legacy keys and runs doctor migration. diff --git a/docs/tools.md b/docs/tools.md index d9d87649d..94bd80a06 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -126,7 +126,7 @@ Core parameters: - `maxBytesMb` (optional size cap) 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). ### `cron` diff --git a/docs/tui.md b/docs/tui.md index fbfbae82c..1585b75ec 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -48,7 +48,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/help` - `/status` - `/session ` (or `/sessions`) -- `/model ` (or `/models`) +- `/model ` (or `/model list`, `/models`) - `/think ` - `/verbose ` - `/elevated ` diff --git a/docs/wizard.md b/docs/wizard.md index 1682cf9ec..ccb885f7d 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -52,7 +52,7 @@ It does **not** install or change anything on the remote host. - **API key**: stores the key for you. - **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **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** - Default `~/clawd` (configurable). diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts index 98a02d3b0..2fe019e75 100644 --- a/src/agents/agent-paths.ts +++ b/src/agents/agent-paths.ts @@ -1,14 +1,13 @@ import path from "node:path"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; - -const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent"); +import { resolveConfigDir, resolveUserPath } from "../utils.js"; export function resolveClawdbotAgentDir(): string { + const defaultAgentDir = path.join(resolveConfigDir(), "agent"); const override = process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim() || - DEFAULT_AGENT_DIR; + defaultAgentDir; return resolveUserPath(override); } diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts new file mode 100644 index 000000000..c9bb4ec25 --- /dev/null +++ b/src/agents/auth-profiles.test.ts @@ -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"); + }); +}); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts new file mode 100644 index 000000000..f3019f755 --- /dev/null +++ b/src/agents/auth-profiles.ts @@ -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; + lastGood?: Record; +}; + +type LegacyAuthStore = Record; + +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; + 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; + 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; + if (!record.profiles || typeof record.profiles !== "object") return null; + const profiles = record.profiles as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(profiles)) { + if (!value || typeof value !== "object") continue; + const typed = value as Partial; + 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) + : 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; + 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 = { + [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; +} diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 5522a12ad..b8a41e3f4 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Api, Model } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; const oauthFixture = { @@ -13,12 +12,16 @@ const oauthFixture = { }; 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 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-")); try { 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"); await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 }); @@ -28,10 +31,6 @@ describe("getApiKeyForModel", () => { "utf8", ); - const agentDir = path.join(tempDir, "agent"); - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - const authStorage = discoverAuthStorage(agentDir); - vi.resetModules(); const { getApiKeyForModel } = await import("./model-auth.js"); @@ -41,18 +40,21 @@ describe("getApiKeyForModel", () => { api: "openai-codex-responses", } as Model; - const apiKey = await getApiKeyForModel(model, authStorage); - expect(apiKey).toBe(oauthFixture.access); + const apiKey = await getApiKeyForModel({ model }); + expect(apiKey.apiKey).toBe(oauthFixture.access); - const authJson = await fs.readFile( - path.join(agentDir, "auth.json"), + const authProfiles = await fs.readFile( + path.join(tempDir, "agent", "auth-profiles.json"), "utf8", ); - const authData = JSON.parse(authJson) as Record; - expect(authData["openai-codex"]).toMatchObject({ - type: "oauth", - access: oauthFixture.access, - refresh: oauthFixture.refresh, + const authData = JSON.parse(authProfiles) as Record; + expect(authData.profiles).toMatchObject({ + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: oauthFixture.access, + refresh: oauthFixture.refresh, + }, }); } finally { if (previousStateDir === undefined) { @@ -60,6 +62,16 @@ describe("getApiKeyForModel", () => { } 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(tempDir, { recursive: true, force: true }); } }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index a497f8a1a..bf29e165b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -1,179 +1,147 @@ -import fsSync from "node:fs"; -import os from "node:os"; -import path from "node:path"; - +import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { ModelProviderConfig } from "../config/types.js"; +import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { - type Api, - getEnvApiKey, - getOAuthApiKey, - type Model, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; -import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; + type AuthProfileStore, + ensureAuthProfileStore, + resolveApiKeyForProfile, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +export { + ensureAuthProfileStore, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; -const OAUTH_FILENAME = "oauth.json"; -const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); -let oauthStorageConfigured = false; -let oauthStorageMigrated = false; - -type OAuthStorage = Record; - -function resolveClawdbotOAuthPath(): string { - const overrideDir = - process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR; - return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME); +export function getCustomProviderApiKey( + cfg: ClawdbotConfig | undefined, + provider: string, +): string | undefined { + const providers = cfg?.models?.providers ?? {}; + const entry = providers[provider] as ModelProviderConfig | undefined; + const key = entry?.apiKey?.trim(); + return key || undefined; } -function loadOAuthStorageAt(pathname: string): OAuthStorage | null { - if (!fsSync.existsSync(pathname)) return null; - try { - const content = fsSync.readFileSync(pathname, "utf8"); - const json = JSON.parse(content) as OAuthStorage; - if (!json || typeof json !== "object") return null; - return json; - } catch { - return null; - } -} +export async function resolveApiKeyForProvider(params: { + provider: string; + cfg?: ClawdbotConfig; + profileId?: string; + preferredProfile?: string; + store?: AuthProfileStore; +}): Promise<{ apiKey: string; profileId?: string; source: string }> { + const { provider, cfg, profileId, preferredProfile } = params; + const store = params.store ?? ensureAuthProfileStore(); -function hasAnthropicOAuth(storage: OAuthStorage): boolean { - const entry = storage.anthropic 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()); -} - -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, -): 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, -): 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, - authStorage: ReturnType, -): Promise { - 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 - } + if (profileId) { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId, + }); + if (!resolved) { + throw new Error(`No credentials found for profile "${profileId}".`); } + 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 = { + 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; + 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, + }); } diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index a8c63d870..96d9abeb5 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -33,7 +33,10 @@ function buildAllowedModelKeys( cfg: ClawdbotConfig | undefined, defaultProvider: string, ): Set | null { - const rawAllowlist = cfg?.agent?.allowedModels ?? []; + const rawAllowlist = (() => { + const modelMap = cfg?.agent?.models ?? {}; + return Object.keys(modelMap); + })(); if (rawAllowlist.length === 0) return null; const keys = new Set(); for (const raw of rawAllowlist) { @@ -81,11 +84,28 @@ function resolveImageFallbackCandidates(params: { if (params.modelOverride?.trim()) { addRaw(params.modelOverride, false); - } else if (params.cfg?.agent?.imageModel?.trim()) { - addRaw(params.cfg.agent.imageModel, false); + } else { + 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); } @@ -121,7 +141,16 @@ function resolveFallbackCandidates(params: { 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({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -224,7 +253,7 @@ export async function runWithImageModelFallback(params: { }); if (candidates.length === 0) { 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.", ); } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index d791763b8..5b107916d 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -5,9 +5,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { resolveConfiguredModelRef } from "./model-selection.js"; describe("resolveConfiguredModelRef", () => { - it("parses provider/model from agent.model", () => { + it("parses provider/model from agent.model.primary", () => { const cfg = { - agent: { model: "openai/gpt-4.1-mini" }, + agent: { model: { primary: "openai/gpt-4.1-mini" } }, } satisfies ClawdbotConfig; const resolved = resolveConfiguredModelRef({ @@ -19,9 +19,9 @@ describe("resolveConfiguredModelRef", () => { 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 = { - agent: { model: "claude-opus-4-5" }, + agent: { model: { primary: "claude-opus-4-5" } }, } satisfies ClawdbotConfig; const resolved = resolveConfiguredModelRef({ @@ -54,9 +54,9 @@ describe("resolveConfiguredModelRef", () => { it("resolves agent.model aliases when configured", () => { const cfg = { agent: { - model: "Opus", - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + model: { primary: "Opus" }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, } satisfies ClawdbotConfig; @@ -72,4 +72,18 @@ describe("resolveConfiguredModelRef", () => { 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" }); + }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 2cda180d1..f342700dd 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -41,18 +41,17 @@ export function buildModelAliasIndex(params: { cfg: ClawdbotConfig; defaultProvider: string; }): ModelAliasIndex { - const rawAliases = params.cfg.agent?.modelAliases ?? {}; const byAlias = new Map(); const byKey = new Map(); - for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) { - const alias = aliasRaw.trim(); - if (!alias) continue; - const parsed = parseModelRef( - String(targetRaw ?? ""), - params.defaultProvider, - ); + const rawModels = params.cfg.agent?.models ?? {}; + for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { + const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider); if (!parsed) continue; + const alias = String( + (entryRaw as { alias?: string } | undefined)?.alias ?? "", + ).trim(); + if (!alias) continue; const aliasKey = normalizeAliasKey(alias); byAlias.set(aliasKey, { alias, ref: parsed }); const key = modelKey(parsed.provider, parsed.model); @@ -88,7 +87,14 @@ export function resolveConfiguredModelRef(params: { defaultProvider: string; defaultModel: string; }): 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) { const trimmed = rawModel.trim(); const aliasIndex = buildModelAliasIndex({ @@ -116,7 +122,10 @@ export function buildAllowedModelSet(params: { allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; } { - const rawAllowlist = params.cfg.agent?.allowedModels ?? []; + const rawAllowlist = (() => { + const modelMap = params.cfg.agent?.models ?? {}; + return Object.keys(modelMap); + })(); const allowAny = rawAllowlist.length === 0; const catalogKeys = new Set( params.catalog.map((entry) => modelKey(entry.provider, entry.id)), diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 7a03e02e8..ef8049e3a 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -120,12 +120,40 @@ export function isRateLimitAssistantError( if (!msg || msg.stopReason !== "error") return false; const raw = (msg.errorMessage ?? "").toLowerCase(); if (!raw) return false; + return isRateLimitErrorMessage(raw); +} + +export function isRateLimitErrorMessage(raw: string): boolean { + const value = raw.toLowerCase(); return ( - /rate[_ ]limit|too many requests|429/.test(raw) || - raw.includes("exceeded your current quota") + /rate[_ ]limit|too many requests|429/.test(value) || + 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[] { const match = raw.match(/supported values are:\s*([^\n.]+)/i) ?? diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 3663ad0b2..28bd4bec6 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -24,15 +24,23 @@ import { } from "../process/command-queue.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; +import { markAuthProfileGood } from "./auth-profiles.js"; import type { BashElevatedDefaults } from "./bash-tools.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 { buildBootstrapContextFiles, ensureSessionHeader, formatAssistantErrorText, + isAuthAssistantError, + isAuthErrorMessage, isRateLimitAssistantError, + isRateLimitErrorMessage, pickFallbackThinkingLevel, sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; @@ -311,6 +319,7 @@ export async function runEmbeddedPiAgent(params: { prompt: string; provider?: string; model?: string; + authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; bashElevated?: BashElevatedDefaults; @@ -368,11 +377,67 @@ export async function runEmbeddedPiAgent(params: { if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); } - const apiKey = await getApiKeyForModel(model, authStorage); - authStorage.setRuntimeApiKey(model.provider, apiKey); - - let thinkLevel = params.thinkLevel ?? "off"; + const authStore = ensureAuthProfileStore(); + const explicitProfileId = params.authProfileId?.trim(); + const profileOrder = resolveAuthProfileOrder({ + 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(); + let apiKeyInfo: Awaited> | null = + null; + + const resolveApiKeyForCandidate = async (candidate?: string) => { + return getApiKeyForModel({ + model, + cfg: params.config, + profileId: candidate, + store: authStore, + }); + }; + + const applyApiKeyInfo = async (candidate?: string): Promise => { + apiKeyInfo = await resolveApiKeyForCandidate(candidate); + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + }; + + const advanceAuthProfile = async (): Promise => { + 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) { const thinkingLevel = mapThinkingLevel(thinkLevel); @@ -611,8 +676,16 @@ export async function runEmbeddedPiAgent(params: { params.abortSignal?.removeEventListener?.("abort", onAbort); } if (promptError && !aborted) { + const errorText = describeUnknownError(promptError); + if ( + (isAuthErrorMessage(errorText) || + isRateLimitErrorMessage(errorText)) && + (await advanceAuthProfile()) + ) { + continue; + } const fallbackThinking = pickFallbackThinkingLevel({ - message: describeUnknownError(promptError), + message: errorText, attempted: attemptedThinking, }); if (fallbackThinking) { @@ -645,13 +718,25 @@ export async function runEmbeddedPiAgent(params: { } const fallbackConfigured = - (params.config?.agent?.modelFallbacks?.length ?? 0) > 0; - if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) { - const message = - lastAssistant?.errorMessage?.trim() || - (lastAssistant ? formatAssistantErrorText(lastAssistant) : "") || - "LLM request rate limited."; - throw new Error(message); + (params.config?.agent?.model?.fallbacks?.length ?? 0) > 0; + const authFailure = isAuthAssistantError(lastAssistant); + const rateLimitFailure = isRateLimitAssistantError(lastAssistant); + if (!aborted && (authFailure || rateLimitFailure)) { + const rotated = await advanceAuthProfile(); + if (rotated) { + 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; @@ -717,6 +802,13 @@ export async function runEmbeddedPiAgent(params: { log.debug( `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 { payloads: payloads.length ? payloads : undefined, meta: { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 0f3ebd500..e39841972 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -24,9 +24,15 @@ import type { AnyAgentTool } from "./common.js"; const DEFAULT_PROMPT = "Describe the image."; function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean { - const primary = cfg?.agent?.imageModel?.trim(); - const fallbacks = cfg?.agent?.imageModelFallbacks ?? []; - return Boolean(primary || fallbacks.length > 0); + const imageModel = cfg?.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | 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( @@ -95,15 +101,18 @@ async function runImagePrompt(params: { `Model does not support images: ${provider}/${modelId}`, ); } - const apiKey = await getApiKeyForModel(model, authStorage); - authStorage.setRuntimeApiKey(model.provider, apiKey); + const apiKeyInfo = await getApiKeyForModel({ + model, + cfg: params.cfg, + }); + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); const context = buildImageContext( params.prompt, params.base64, params.mimeType, ); const message = (await complete(model, context, { - apiKey, + apiKey: apiKeyInfo.apiKey, maxTokens: 512, temperature: 0, })) as AssistantMessage; diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 834e3daa8..56bb6e19e 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -1,19 +1,28 @@ export function extractModelDirective(body?: string): { cleaned: string; rawModel?: string; + rawProfile?: string; hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; 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 ? body.replace(match[0], "").replace(/\s+/g, " ").trim() : body.trim(); return { cleaned, rawModel, + rawProfile, hasDirective: !!match, }; } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 92ffdc410..062c2099c 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -37,11 +37,24 @@ vi.mock("../agents/model-catalog.js", () => ({ async function withTempHome(fn: (home: string) => Promise): Promise { const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-")); 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.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 { return await fn(base); } finally { 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 }); } } @@ -566,9 +579,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, 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 }, }, @@ -593,9 +609,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, 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 }, }, @@ -620,9 +639,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, 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 }, }, @@ -646,9 +668,11 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["anthropic/claude-opus-4-5"], + models: { + "anthropic/claude-opus-4-5": {}, + }, }, session: { store: storePath }, }, @@ -671,9 +695,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, 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 }, }, @@ -699,11 +726,11 @@ describe("directive parsing", () => { {}, { agent: { - model: "openai/gpt-4.1-mini", + model: { primary: "openai/gpt-4.1-mini" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, 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 () => { await withTempHome(async (home) => { drainSystemEvents(); @@ -732,11 +808,11 @@ describe("directive parsing", () => { {}, { agent: { - model: "openai/gpt-4.1-mini", + model: { primary: "openai/gpt-4.1-mini" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, session: { store: storePath }, @@ -771,9 +847,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, whatsapp: { allowFrom: ["*"], diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 703884bfb..26144ef64 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -361,7 +361,9 @@ export async function getReplyFromConfig( : `Model switched to ${label}.`; const isModelListAlias = directives.hasModelDirective && - directives.rawModelDirective?.trim().toLowerCase() === "status"; + ["status", "list"].includes( + directives.rawModelDirective?.trim().toLowerCase() ?? "", + ); const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective; @@ -376,6 +378,7 @@ export async function getReplyFromConfig( }) ) { const directiveReply = await handleDirectiveOnly({ + cfg, directives, sessionEntry, sessionStore, @@ -401,6 +404,7 @@ export async function getReplyFromConfig( const persisted = await persistInlineDirectives({ directives, effectiveModelDirective, + cfg, sessionEntry, sessionStore, sessionKey, @@ -634,6 +638,7 @@ export async function getReplyFromConfig( resolvedQueue.mode === "followup" || resolvedQueue.mode === "collect" || resolvedQueue.mode === "steer-backlog"; + const authProfileId = sessionEntry?.authProfileOverride; const followupRun = { prompt: queuedBody, summaryLine: baseBodyTrimmedRaw, @@ -648,6 +653,7 @@ export async function getReplyFromConfig( skillsSnapshot, provider, model, + authProfileId, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, elevatedLevel: resolvedElevatedLevel, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 5ad34c387..fd2a8eef1 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -195,6 +195,7 @@ export async function runReplyAgent(params: { enforceFinalTag: followupRun.run.enforceFinalTag, provider, model, + authProfileId: followupRun.run.authProfileId, thinkLevel: followupRun.run.thinkLevel, verboseLevel: followupRun.run.verboseLevel, bashElevated: followupRun.run.bashElevated, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d225e28bf..d8042911b 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,10 +1,12 @@ -import fs from "node:fs"; - -import { getEnvApiKey } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; -import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveOAuthPath } from "../../config/paths.js"; import { type SessionEntry, type SessionScope, @@ -42,55 +44,32 @@ export type CommandContext = { to?: string; }; -function hasOAuthCredentials(provider: string): boolean { - try { - const oauthPath = resolveOAuthPath(); - if (!fs.existsSync(oauthPath)) return false; - const raw = fs.readFileSync(oauthPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - 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 { +function resolveModelAuthLabel( + provider?: string, + cfg?: ClawdbotConfig, +): string | undefined { const resolved = provider?.trim(); if (!resolved) return undefined; - try { - const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); - const stored = authStorage.get(resolved); - if (stored?.type === "oauth") return "oauth"; - if (stored?.type === "api_key") return "api-key"; - } catch { - // ignore auth storage errors + const store = ensureAuthProfileStore(); + const profiles = listProfilesForProvider(store, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => store.profiles[id]?.type) + .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 oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; - if (oauthEnv?.trim()) return "oauth"; + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; } - if (hasOAuthCredentials(resolved)) return "oauth"; - - const envKey = getEnvApiKey(resolved); - if (envKey?.trim()) return "api-key"; + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; return "unknown"; } @@ -374,7 +353,7 @@ export async function handleCommands(params: { resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedVerbose: resolvedVerboseLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider), + modelAuth: resolveModelAuthLabel(provider, cfg), webLinked, webAuthAgeMs, heartbeatSeconds, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 5ccae73e8..7b3c240ae 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -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 { + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, +} from "../../agents/auth-profiles.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../../agents/defaults.js"; -import { hydrateAuthStorage } from "../../agents/model-auth.js"; +import { + ensureAuthProfileStore, + getCustomProviderApiKey, + resolveAuthProfileOrder, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { buildModelAliasIndex, type ModelAliasIndex, @@ -53,43 +60,63 @@ const maskApiKey = (value: string): string => { const resolveAuthLabel = async ( provider: string, - authStorage: ReturnType, - authPaths: { authPath: string; modelsPath: string }, + cfg: ClawdbotConfig, + modelsPath: string, ): Promise<{ label: string; source: string }> => { const formatPath = (value: string) => shortenHomePath(value); - const stored = authStorage.get(provider); - if (stored?.type === "oauth") { - const email = stored.email?.trim(); + const store = ensureAuthProfileStore(); + const order = resolveAuthProfileOrder({ cfg, store, provider }); + 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 { - label: email ? `OAuth ${email}` : "OAuth (unknown)", - source: `auth.json: ${formatPath(authPaths.authPath)}`, + label: labels.join(", "), + 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 { - label: maskApiKey(stored.key), - source: `auth.json: ${formatPath(authPaths.authPath)}`, + label: maskApiKey(customKey), + 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" }; }; @@ -100,6 +127,26 @@ const formatAuthLabel = (auth: { label: string; source: string }) => { 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 = { cleaned: string; hasThinkDirective: boolean; @@ -114,6 +161,7 @@ export type InlineDirectives = { hasStatusDirective: boolean; hasModelDirective: boolean; rawModelDirective?: string; + rawModelProfile?: string; hasQueueDirective: boolean; queueMode?: QueueMode; queueReset: boolean; @@ -151,6 +199,7 @@ export function parseInlineDirectives(body: string): InlineDirectives { const { cleaned: modelCleaned, rawModel, + rawProfile, hasDirective: hasModelDirective, } = extractModelDirective(statusCleaned); const { @@ -182,6 +231,7 @@ export function parseInlineDirectives(body: string): InlineDirectives { hasStatusDirective, hasModelDirective, rawModelDirective: rawModel, + rawModelProfile: rawProfile, hasQueueDirective, queueMode, queueReset, @@ -218,6 +268,7 @@ export function isDirectiveOnly(params: { } export async function handleDirectiveOnly(params: { + cfg: ClawdbotConfig; directives: InlineDirectives; sessionEntry?: SessionEntry; sessionStore?: Record; @@ -265,19 +316,14 @@ export async function handleDirectiveOnly(params: { return { text: "No models available." }; } const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - const authPaths = { - authPath: `${agentDir}/auth.json`, - modelsPath: `${agentDir}/models.json`, - }; - hydrateAuthStorage(authStorage); + const modelsPath = `${agentDir}/models.json`; const authByProvider = new Map(); for (const entry of allowedModelCatalog) { if (authByProvider.has(entry.provider)) continue; const auth = await resolveAuthLabel( entry.provider, - authStorage, - authPaths, + params.cfg, + modelsPath, ); authByProvider.set(entry.provider, formatAuthLabel(auth)); } @@ -306,6 +352,9 @@ export async function handleDirectiveOnly(params: { } return { text: lines.join("\n") }; } + if (directives.rawModelProfile && !modelDirective) { + throw new Error("Auth profile override requires a model selection."); + } } if (directives.hasThinkDirective && !directives.thinkLevel) { @@ -378,6 +427,7 @@ export async function handleDirectiveOnly(params: { } let modelSelection: ModelDirectiveSelection | undefined; + let profileOverride: string | undefined; if (directives.hasModelDirective && directives.rawModelDirective) { const resolved = resolveModelDirectiveSelection({ raw: directives.rawModelDirective, @@ -391,6 +441,17 @@ export async function handleDirectiveOnly(params: { } modelSelection = resolved.selection; 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}`; if (nextLabel !== initialModelLabel) { 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 (directives.hasThinkDirective && directives.thinkLevel) { @@ -424,6 +488,11 @@ export async function handleDirectiveOnly(params: { sessionEntry.providerOverride = modelSelection.provider; sessionEntry.modelOverride = modelSelection.model; } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } } if (directives.hasQueueDirective && directives.queueReset) { delete sessionEntry.queueMode; @@ -481,6 +550,9 @@ export async function handleDirectiveOnly(params: { ? `Model reset to default (${labelWithAlias}).` : `Model set to ${labelWithAlias}.`, ); + if (profileOverride) { + parts.push(`Auth profile set to ${profileOverride}.`); + } } if (directives.hasQueueDirective && 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: { directives: InlineDirectives; effectiveModelDirective?: string; + cfg: ClawdbotConfig; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -526,6 +599,7 @@ export async function persistInlineDirectives(params: { }): Promise<{ provider: string; model: string; contextTokens: number }> { const { directives, + cfg, sessionEntry, sessionStore, sessionKey, @@ -586,6 +660,18 @@ export async function persistInlineDirectives(params: { if (resolved) { const key = modelKey(resolved.ref.provider, resolved.ref.model); 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 = resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel; @@ -596,6 +682,11 @@ export async function persistInlineDirectives(params: { sessionEntry.providerOverride = resolved.ref.provider; sessionEntry.modelOverride = resolved.ref.model; } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } provider = resolved.ref.provider; model = resolved.ref.model; const nextLabel = `${provider}/${model}`; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 86f978ec3..ebb6d5cfa 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -84,6 +84,7 @@ export function createFollowupRunner(params: { enforceFinalTag: queued.run.enforceFinalTag, provider, model, + authProfileId: queued.run.authProfileId, thinkLevel: queued.run.thinkLevel, verboseLevel: queued.run.verboseLevel, bashElevated: queued.run.bashElevated, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 5ee553208..a4cb67359 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -57,7 +57,8 @@ export async function createModelSelectionState(params: { let provider = params.provider; let model = params.model; - const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasAllowlist = + agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasStoredOverride = Boolean( 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; const resolveDefaultThinkingLevel = async () => { if (defaultThinkingLevel) return defaultThinkingLevel; diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 074089a4e..47b0dc432 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -32,6 +32,7 @@ export type FollowupRun = { skillsSnapshot?: SkillSnapshot; provider: string; model: string; + authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; elevatedLevel?: ElevatedLevel; diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index c407d9666..b25a95304 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -59,7 +59,8 @@ function mockConfig( ) { configSpy.mockReturnValue({ 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"), ...agentOverrides, }, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e6306ae66..18599c6a0 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -289,7 +290,8 @@ export async function agentCommand( }); let provider = defaultProvider; let model = defaultModel; - const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasAllowlist = + agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasStoredOverride = Boolean( sessionEntry?.modelOverride || sessionEntry?.providerOverride, ); @@ -335,6 +337,18 @@ export async function agentCommand( 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) { let catalogForThinking = modelCatalog ?? allowedModelCatalog; @@ -381,6 +395,7 @@ export async function agentCommand( prompt: body, provider: providerOverride, model: modelOverride, + authProfileId: sessionEntry?.authProfileOverride, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs, diff --git a/src/commands/configure.ts b/src/commands/configure.ts index dda13dc83..9d61541d4 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -32,6 +32,7 @@ import { } from "./antigravity-oauth.js"; import { healthCommand } from "./health.js"; import { + applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, writeOAuthCredentials, @@ -275,6 +276,11 @@ async function promptAuthConfig( spin.stop("OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("anthropic", oauthCreds); + next = applyAuthProfileConfig(next, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "oauth", + }); } } catch (err) { spin.stop("OAuth failed"); @@ -316,12 +322,30 @@ async function promptAuthConfig( spin.stop("Antigravity OAuth complete"); if (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 next = { ...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( @@ -342,6 +366,11 @@ async function promptAuthConfig( runtime, ); await setAnthropicApiKey(String(key).trim()); + next = applyAuthProfileConfig(next, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { next = applyMinimaxConfig(next); } @@ -349,7 +378,10 @@ async function promptAuthConfig( const modelInput = guardCancel( await text({ message: "Default model (blank to keep)", - initialValue: next.agent?.model ?? "", + initialValue: + typeof next.agent?.model === "string" + ? next.agent?.model + : (next.agent?.model?.primary ?? ""), }), runtime, ); @@ -359,7 +391,17 @@ async function promptAuthConfig( ...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] ?? {}, + }, }, }; } diff --git a/src/commands/models/aliases.ts b/src/commands/models/aliases.ts index fe670e9f0..9600b7494 100644 --- a/src/commands/models/aliases.ts +++ b/src/commands/models/aliases.ts @@ -13,7 +13,15 @@ export async function modelsAliasesListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const aliases = cfg.agent?.modelAliases ?? {}; + const models = cfg.agent?.models ?? {}; + const aliases = Object.entries(models).reduce>( + (acc, [modelKey, entry]) => { + const alias = entry?.alias?.trim(); + if (alias) acc[alias] = modelKey; + return acc; + }, + {}, + ); if (opts.json) { runtime.log(JSON.stringify({ aliases }, null, 2)); @@ -42,21 +50,29 @@ export async function modelsAliasesAddCommand( runtime: RuntimeEnv, ) { const alias = normalizeAlias(aliasRaw); - const updated = await updateConfig((cfg) => { - const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const nextAliases = { ...cfg.agent?.modelAliases }; - nextAliases[alias] = `${resolved.provider}/${resolved.model}`; + const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() }); + const _updated = await updateConfig((cfg) => { + const modelKey = `${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 { ...cfg, agent: { ...cfg.agent, - modelAliases: nextAliases, + models: nextModels, }, }; }); 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( @@ -65,24 +81,31 @@ export async function modelsAliasesRemoveCommand( ) { const alias = normalizeAlias(aliasRaw); const updated = await updateConfig((cfg) => { - const nextAliases = { ...cfg.agent?.modelAliases }; - if (!nextAliases[alias]) { + const nextModels = { ...cfg.agent?.models }; + 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}`); } - delete nextAliases[alias]; return { ...cfg, agent: { ...cfg.agent, - modelAliases: nextAliases, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); if ( - !updated.agent?.modelAliases || - Object.keys(updated.agent.modelAliases).length === 0 + !updated.agent?.models || + Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim()) ) { runtime.log("No aliases configured."); } diff --git a/src/commands/models/fallbacks.ts b/src/commands/models/fallbacks.ts index 81f825abb..3722fabfa 100644 --- a/src/commands/models/fallbacks.ts +++ b/src/commands/models/fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.modelFallbacks ?? []; + const fallbacks = cfg.agent?.model?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,11 +44,13 @@ export async function modelsFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.modelFallbacks ?? []; + const existing = cfg.agent?.model?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -66,13 +68,22 @@ export async function modelsFallbacksAddCommand( ...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(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); + runtime.log( + `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + ); } export async function modelsFallbacksRemoveCommand( @@ -86,7 +97,7 @@ export async function modelsFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.modelFallbacks ?? []; + const existing = cfg.agent?.model?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -108,13 +119,21 @@ export async function modelsFallbacksRemoveCommand( ...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(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); + runtime.log( + `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + ); } export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { @@ -122,7 +141,11 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { ...cfg, agent: { ...cfg.agent, - modelFallbacks: [], + model: { + ...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ?? + {}), + fallbacks: [], + }, }, })); diff --git a/src/commands/models/image-fallbacks.ts b/src/commands/models/image-fallbacks.ts index f4a941b8a..5fcff8bd4 100644 --- a/src/commands/models/image-fallbacks.ts +++ b/src/commands/models/image-fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.imageModelFallbacks ?? []; + const fallbacks = cfg.agent?.imageModel?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,11 +44,13 @@ export async function modelsImageFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModelFallbacks ?? []; + const existing = cfg.agent?.imageModel?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -66,14 +68,21 @@ export async function modelsImageFallbacksAddCommand( ...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( - `Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, ); } @@ -88,7 +97,7 @@ export async function modelsImageFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModelFallbacks ?? []; + const existing = cfg.agent?.imageModel?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -110,14 +119,20 @@ export async function modelsImageFallbacksRemoveCommand( ...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( - `Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, ); } @@ -126,7 +141,13 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) { ...cfg, agent: { ...cfg.agent, - imageModelFallbacks: [], + imageModel: { + ...((cfg.agent?.imageModel as { + primary?: string; + fallbacks?: string[]; + }) ?? {}), + fallbacks: [], + }, }, })); diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index e061cf5d3..7a8fb7858 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -1,4 +1,4 @@ -import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai"; +import type { Api, Model } from "@mariozechner/pi-ai"; import { discoverAuthStorage, discoverModels, @@ -6,6 +6,15 @@ import { import chalk from "chalk"; 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 { buildModelAliasIndex, 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 resolvedDefault = resolveConfiguredModelRef({ cfg, @@ -110,7 +130,21 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { 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({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -120,17 +154,16 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolved.ref, `fallback#${idx + 1}`); }); - const imageModelRaw = cfg.agent?.imageModel?.trim(); - if (imageModelRaw) { + if (imagePrimary) { const resolved = resolveModelRefFromString({ - raw: imageModelRaw, + raw: imagePrimary, defaultProvider: DEFAULT_PROVIDER, aliasIndex, }); if (resolved) addEntry(resolved.ref, "image"); } - (cfg.agent?.imageModelFallbacks ?? []).forEach((raw, idx) => { + imageFallbacks.forEach((raw, idx) => { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -140,20 +173,10 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolved.ref, `img-fallback#${idx + 1}`); }); - (cfg.agent?.allowedModels ?? []).forEach((raw) => { - const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); - if (!parsed) return; - addEntry(parsed, "allowed"); - }); - - 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"); + for (const key of Object.keys(cfg.agent?.models ?? {})) { + const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + addEntry(parsed, "configured"); } const entries: ConfiguredEntry[] = order.map((key) => { @@ -190,8 +213,18 @@ function toModelRow(params: { tags: string[]; aliases?: string[]; availableKeys?: Set; + cfg?: ClawdbotConfig; + authStore?: AuthProfileStore; }): ModelRow { - const { model, key, tags, aliases = [], availableKeys } = params; + const { + model, + key, + tags, + aliases = [], + availableKeys, + cfg, + authStore, + } = params; if (!model) { return { key, @@ -207,9 +240,11 @@ function toModelRow(params: { const input = model.input.join("+") || "text"; const local = isLocalBaseUrl(model.baseUrl); - const envKey = getEnvApiKey(model.provider); 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 mergedTags = new Set(tags); if (aliasTags.length > 0) { @@ -304,6 +339,7 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); + const authStore = ensureAuthProfileStore(); const providerFilter = opts.provider?.trim().toLowerCase(); let models: Model[] = []; @@ -346,6 +382,8 @@ export async function modelsListCommand( tags: configured ? Array.from(configured.tags) : [], aliases: configured?.aliases ?? [], availableKeys, + cfg, + authStore, }), ); } @@ -367,6 +405,8 @@ export async function modelsListCommand( tags: Array.from(entry.tags), aliases: entry.aliases, availableKeys, + cfg, + authStore, }), ); } @@ -392,13 +432,35 @@ export async function modelsStatusCommand( 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 fallbacks = cfg.agent?.modelFallbacks ?? []; - const imageModel = cfg.agent?.imageModel?.trim() ?? ""; - const imageFallbacks = cfg.agent?.imageModelFallbacks ?? []; - const aliases = cfg.agent?.modelAliases ?? {}; - const allowed = cfg.agent?.allowedModels ?? []; + const fallbacks = + typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : []; + const imageModel = + typeof imageConfig === "string" + ? imageConfig.trim() + : (imageConfig?.primary?.trim() ?? ""); + const imageFallbacks = + typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : []; + const aliases = Object.entries(cfg.agent?.models ?? {}).reduce< + Record + >((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) { runtime.log( @@ -446,6 +508,8 @@ export async function modelsStatusCommand( }`, ); runtime.log( - `Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`, + `Configured models (${allowed.length || 0}): ${ + allowed.length ? allowed.join(", ") : "all" + }`, ); } diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 4cb3b858f..416a220de 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,20 +1,12 @@ import { cancel, isCancel, multiselect } from "@clack/prompts"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; - -import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, scanOpenRouterModels, } from "../../agents/model-scan.js"; -import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; -import { warn } from "../../globals.js"; +import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - formatMs, - formatTokenK, - updateConfig, -} from "./shared.js"; +import { formatMs, formatTokenK, updateConfig } from "./shared.js"; const MODEL_PAD = 42; const CTX_PAD = 8; @@ -181,8 +173,17 @@ export async function modelsScanCommand( throw new Error("--concurrency must be > 0"); } - const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); - const storedKey = await authStorage.getApiKey("openrouter"); + const cfg = loadConfig(); + let storedKey: string | undefined; + try { + const resolved = await resolveApiKeyForProvider({ + provider: "openrouter", + cfg, + }); + storedKey = resolved.apiKey; + } catch { + storedKey = undefined; + } const results = await scanOpenRouterModels({ apiKey: storedKey ?? undefined, minParamB: minParams, @@ -266,32 +267,42 @@ export async function modelsScanCommand( 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 = { ...cfg.agent, - modelFallbacks: selected, - ...(opts.setDefault ? { model: selected[0] } : {}), - ...(opts.setImage && selectedImages.length > 0 - ? { imageModel: selectedImages[0] } - : {}), + model: { + ...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ?? + {}), + fallbacks: selected, + ...(opts.setDefault ? { primary: selected[0] } : {}), + }, + ...(nextImageModel ? { imageModel: nextImageModel } : {}), + models: nextModels, } satisfies NonNullable; - if (imageSorted.length > 0) { - agent.imageModelFallbacks = selectedImages; - } return { ...cfg, 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) { runtime.log( JSON.stringify( @@ -301,21 +312,7 @@ export async function modelsScanCommand( setDefault: Boolean(opts.setDefault), setImage: Boolean(opts.setImage), results, - 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(", ")}`, - ] - : []), - ] - : [], + warnings: [], }, null, 2, @@ -324,21 +321,6 @@ export async function modelsScanCommand( 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(`Fallbacks: ${selected.join(", ")}`); if (selectedImages.length > 0) { diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index 6613b2e98..46214ee9a 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -1,11 +1,6 @@ import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { resolveModelTarget, updateConfig } from "./shared.js"; export async function modelsSetImageCommand( modelRaw: string, @@ -13,22 +8,25 @@ export async function modelsSetImageCommand( ) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const allowlist = buildAllowlistSet(cfg); - if (allowlist.size > 0) { - const key = modelKey(resolved.provider, resolved.model); - if (!allowlist.has(key)) { - throw new Error(`Model ${key} is not in agent.allowedModels.`); - } - } + const key = `${resolved.provider}/${resolved.model}`; + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[key]) nextModels[key] = {}; return { ...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(`Image model: ${updated.agent?.imageModel ?? modelRaw}`); + runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`); } diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 20e500519..d8546c484 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,31 +1,29 @@ import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { resolveModelTarget, updateConfig } from "./shared.js"; export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const allowlist = buildAllowlistSet(cfg); - if (allowlist.size > 0) { - const key = modelKey(resolved.provider, resolved.model); - if (!allowlist.has(key)) { - throw new Error(`Model ${key} is not in agent.allowedModels.`); - } - } + const key = `${resolved.provider}/${resolved.model}`; + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[key]) nextModels[key] = {}; return { ...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(`Default model: ${updated.agent?.model ?? modelRaw}`); + runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`); } diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 347ae06d0..a8d305998 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -69,7 +69,8 @@ export function resolveModelTarget(params: { export function buildAllowlistSet(cfg: ClawdbotConfig): Set { const allowed = new Set(); - 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); if (!parsed) continue; allowed.add(modelKey(parsed.provider, parsed.model)); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 4187d1961..71526c61d 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -5,11 +5,12 @@ import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveOAuthPath } from "../config/paths.js"; import { writeOAuthCredentials } from "./onboard-auth.js"; describe("writeOAuthCredentials", () => { 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; afterEach(async () => { @@ -22,12 +23,24 @@ describe("writeOAuthCredentials", () => { } 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; + } 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-")); 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 = { refresh: "refresh-token", @@ -37,16 +50,19 @@ describe("writeOAuthCredentials", () => { await writeOAuthCredentials("anthropic", creds); - const oauthPath = resolveOAuthPath(); - expect(oauthPath).toBe( - path.join(tempStateDir, "credentials", "oauth.json"), + const authProfilePath = path.join( + tempStateDir, + "agent", + "auth-profiles.json", ); - - const raw = await fs.readFile(oauthPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - expect(parsed.anthropic).toMatchObject({ + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["anthropic:default"]).toMatchObject({ refresh: "refresh-token", access: "access-token", + type: "oauth", }); }); }); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 16b076a3d..94a272958 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,47 +1,73 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; - -import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { resolveOAuthPath } from "../config/paths.js"; export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, ): Promise { - const filePath = resolveOAuthPath(); - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - let storage: Record = {}; - try { - const raw = await fs.readFile(filePath, "utf8"); - const parsed = JSON.parse(raw) as Record; - 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); + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: { + type: "oauth", + provider, + ...creds, + }, + }); } export async function setAnthropicApiKey(key: string) { - const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - authStorage.set("anthropic", { type: "api_key", key }); + upsertAuthProfile({ + profileId: "anthropic:default", + 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 { - const allowed = new Set(cfg.agent?.allowedModels ?? []); - allowed.add("anthropic/claude-opus-4-5"); - allowed.add("lmstudio/minimax-m2.1-gs32"); - - const aliases = { ...cfg.agent?.modelAliases }; - if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; - if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; + const models = { ...cfg.agent?.models }; + models["anthropic/claude-opus-4-5"] = { + ...models["anthropic/claude-opus-4-5"], + alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus", + }; + models["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 }; if (!providers.lmstudio) { @@ -67,9 +93,12 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { ...cfg, agent: { ...cfg.agent, - model: "Minimax", - allowedModels: Array.from(allowed), - modelAliases: aliases, + model: { + ...((cfg.agent?.model as { primary?: string; fallbacks?: string[] }) ?? + {}), + primary: "lmstudio/minimax-m2.1-gs32", + }, + models, }, models: { mode: cfg.models?.mode ?? "merge", diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 3e6ff3a87..0e81bf768 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -33,7 +33,13 @@ export function summarizeExistingConfig(config: ClawdbotConfig): string { const rows: string[] = []; if (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 (typeof config.gateway?.port === "number") { rows.push(`gateway.port: ${config.gateway.port}`); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 016c6fd3b..17d845cd7 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -14,7 +14,11 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import { healthCommand } from "./health.js"; -import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; +import { + applyAuthProfileConfig, + applyMinimaxConfig, + setAnthropicApiKey, +} from "./onboard-auth.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -98,6 +102,11 @@ export async function runNonInteractiveOnboarding( return; } await setAnthropicApiKey(key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); } else if ( diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index f6ef8c626..de68266e1 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -12,7 +12,11 @@ vi.mock("../config/config.js", async (importOriginal) => { return { ...actual, loadConfig: () => ({ - agent: { model: "pi:opus", contextTokens: 32000 }, + agent: { + model: { primary: "pi:opus" }, + models: { "pi:opus": {} }, + contextTokens: 32000, + }, }), }; }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index cc36943cb..3bc176df9 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -8,7 +8,7 @@ import { ensureAgentWorkspace, } from "../agents/workspace.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 type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -31,7 +31,7 @@ async function readConfigFileRaw(): Promise<{ async function writeConfigFile(cfg: ClawdbotConfig) { 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() .concat("\n"); await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8"); diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ece86c2af..88de32c84 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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 () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); @@ -641,6 +653,38 @@ describe("legacy config detection", () => { 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 () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 2fb0eba8d..11a23699a 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -92,43 +92,23 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig { }; } -function normalizeAliasKey(value: string): string { - return value.trim().toLowerCase(); -} - -export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig { +export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig { const existingAgent = cfg.agent; if (!existingAgent) return cfg; - const existingAliases = existingAgent?.modelAliases ?? {}; - - const byNormalized = new Map(); - for (const key of Object.keys(existingAliases)) { - const norm = normalizeAliasKey(key); - if (!norm) continue; - if (!byNormalized.has(norm)) byNormalized.set(norm, key); - } + const existingModels = existingAgent.models ?? {}; + if (Object.keys(existingModels).length === 0) return cfg; let mutated = false; - const nextAliases: Record = { ...existingAliases }; + const nextModels: Record = { + ...existingModels, + }; - for (const [canonicalKey, target] of Object.entries(DEFAULT_MODEL_ALIASES)) { - const norm = normalizeAliasKey(canonicalKey); - const existingKey = byNormalized.get(norm); - - if (!existingKey) { - nextAliases[canonicalKey] = target; - 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; - } + for (const [alias, target] of Object.entries(DEFAULT_MODEL_ALIASES)) { + const entry = nextModels[target]; + if (!entry) continue; + if (entry.alias !== undefined) continue; + nextModels[target] = { ...entry, alias }; + mutated = true; } if (!mutated) return cfg; @@ -137,7 +117,7 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig { ...cfg, agent: { ...existingAgent, - modelAliases: nextAliases, + models: nextModels, }, }; } diff --git a/src/config/io.ts b/src/config/io.ts index ca04943b1..878dc0cc7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -11,7 +11,7 @@ import { import { applyIdentityDefaults, applyLoggingDefaults, - applyModelAliasDefaults, + applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; @@ -114,7 +114,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } return {}; } - const cfg = applyModelAliasDefaults( + const cfg = applyModelDefaults( applySessionDefaults( applyLoggingDefaults( applyIdentityDefaults(validated.data as ClawdbotConfig), @@ -148,7 +148,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey( - applyModelAliasDefaults(applySessionDefaults({})), + applyModelDefaults(applySessionDefaults({})), ); const legacyIssues: LegacyConfigIssue[] = []; return { @@ -204,7 +204,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: true, config: applyTalkApiKey( - applyModelAliasDefaults( + applyModelDefaults( applySessionDefaults(applyLoggingDefaults(validated.config)), ), ), @@ -229,7 +229,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { await deps.fs.promises.mkdir(path.dirname(configPath), { recursive: true, }); - const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) + const json = JSON.stringify(applyModelDefaults(cfg), null, 2) .trimEnd() .concat("\n"); await deps.fs.promises.writeFile(configPath, json, "utf-8"); diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 9c633724b..10f9cfe13 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -3,6 +3,7 @@ import type { LegacyConfigIssue } from "./types.js"; type LegacyConfigRule = { path: string[]; message: string; + match?: (value: unknown, root: Record) => boolean; }; type LegacyConfigMigration = { @@ -27,6 +28,38 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ message: '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[] = [ @@ -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) + : 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) + : {}; + + 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) + : {}; + + 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) + : {}; + if (!("alias" in entry)) { + entry.alias = alias; + models[target] = entry; + } + } + + const currentModel = + agent.model && typeof agent.model === "object" + ? (agent.model as Record) + : 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) + : 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[] { @@ -180,7 +365,7 @@ export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { } cursor = (cursor as Record)[key]; } - if (cursor !== undefined) { + if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) { issues.push({ path: rule.path.join("."), message: rule.message }); } } diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index 352608ec9..cf11f6c0e 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -1,90 +1,56 @@ import { describe, expect, it } from "vitest"; -import { applyLoggingDefaults, applyModelAliasDefaults } from "./defaults.js"; +import { applyModelDefaults } from "./defaults.js"; import type { ClawdbotConfig } from "./types.js"; -describe("applyModelAliasDefaults", () => { - it("adds default shorthands", () => { - const cfg = { agent: {} } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); +describe("applyModelDefaults", () => { + it("adds default aliases when models are present", () => { + const cfg = { + agent: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-5.2": {}, + }, + }, + } satisfies ClawdbotConfig; + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases).toEqual({ - opus: "anthropic/claude-opus-4-5", - sonnet: "anthropic/claude-sonnet-4-5", - gpt: "openai/gpt-5.2", - "gpt-mini": "openai/gpt-5-mini", - gemini: "google/gemini-3-pro-preview", - "gemini-flash": "google/gemini-3-flash-preview", - }); + expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( + "opus", + ); + expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt"); }); - it("normalizes casing when alias matches the default target", () => { + it("does not override existing aliases", () => { const cfg = { - agent: { modelAliases: { Opus: "anthropic/claude-opus-4-5" } }, + agent: { + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, + }, } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases).toMatchObject({ - opus: "anthropic/claude-opus-4-5", - }); - expect(next.agent?.modelAliases).not.toHaveProperty("Opus"); + expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( + "Opus", + ); }); - it("does not override existing alias values", () => { + it("respects explicit empty alias disables", () => { const cfg = { - agent: { modelAliases: { gpt: "openai/gpt-4.1" } }, + agent: { + models: { + "google/gemini-3-pro-preview": { alias: "" }, + "google/gemini-3-flash-preview": {}, + }, + }, } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases?.gpt).toBe("openai/gpt-4.1"); - expect(next.agent?.modelAliases).toMatchObject({ - "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( + expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe(""); + expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe( "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"); - }); -}); diff --git a/src/config/paths.ts b/src/config/paths.ts index 1f6558625..134062561 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { resolveUserPath } from "../utils.js"; import type { ClawdbotConfig } from "./types.js"; /** @@ -33,6 +32,15 @@ export function resolveStateDir( 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(); /** diff --git a/src/config/schema.ts b/src/config/schema.ts index b09b26a4a..ab582bab9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -87,10 +87,13 @@ const FIELD_LABELS: Record = { "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", "agent.workspace": "Workspace", - "agent.model": "Default Model", - "agent.imageModel": "Image Model", - "agent.modelFallbacks": "Model Fallbacks", - "agent.imageModelFallbacks": "Image Model Fallbacks", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "agent.models": "Models", + "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", "browser.controlUrl": "Browser Control URL", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", @@ -114,12 +117,18 @@ const FIELD_HELP: Record = { 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": "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.", - "agent.imageModel": - "Optional image-capable model (provider/model) used by the image tool.", - "agent.imageModelFallbacks": - "Ordered fallback image models (provider/model) used by the image tool.", + "agent.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agent.imageModel.fallbacks": + "Ordered fallback image models (provider/model).", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", }; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index e7ff6a2f5..049d4420a 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -34,6 +34,7 @@ export type SessionEntry = { elevatedLevel?: string; providerOverride?: string; modelOverride?: string; + authProfileOverride?: string; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; diff --git a/src/config/types.ts b/src/config/types.ts index 29633cb6c..9e8feb291 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -639,7 +639,28 @@ export type ModelsConfig = { providers?: Record; }; +export type AuthProfileConfig = { + provider: string; + mode: "api_key" | "oauth"; + email?: string; +}; + +export type AuthConfig = { + profiles?: Record; + order?: Record; +}; + +export type AgentModelEntryConfig = { + alias?: string; +}; + +export type AgentModelListConfig = { + primary?: string; + fallbacks?: string[]; +}; + export type ClawdbotConfig = { + auth?: AuthConfig; env?: { /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ shellEnv?: { @@ -669,22 +690,16 @@ export type ClawdbotConfig = { skills?: SkillsConfig; models?: ModelsConfig; agent?: { - /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ - model?: string; - /** Optional image-capable model (provider/model) used by the image tool. */ - imageModel?: string; + /** Primary model and fallbacks (provider/model). */ + model?: AgentModelListConfig; + /** Optional image-capable model and fallbacks (provider/model). */ + imageModel?: AgentModelListConfig; + /** Model catalog with optional aliases (full provider/model keys). */ + models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ userTimezone?: string; - /** Optional allowlist for /model (provider/model or model-only). */ - allowedModels?: string[]; - /** Optional model aliases for /model (alias -> provider/model). */ - modelAliases?: Record; - /** 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). */ contextTokens?: number; /** Default thinking level when no /think directive is present. */ diff --git a/src/config/validation.ts b/src/config/validation.ts index ecf57d8ab..9c6378753 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,6 +1,6 @@ import { applyIdentityDefaults, - applyModelAliasDefaults, + applyModelDefaults, applySessionDefaults, } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; @@ -34,7 +34,7 @@ export function validateConfigObject( } return { ok: true, - config: applyModelAliasDefaults( + config: applyModelDefaults( applySessionDefaults( applyIdentityDefaults(validated.data as ClawdbotConfig), ), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index bc347f8d0..51cd99726 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -373,17 +373,46 @@ export const ClawdbotSchema = z.object({ seamColor: HexColorSchema.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, agent: z .object({ - model: z.string().optional(), - imageModel: z.string().optional(), + model: z + .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(), 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(), tools: z .object({ diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 157cc1a4c..9010edcd9 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; +let lastAppliedKeys: string[] = []; function isTruthy(raw: string | undefined): boolean { if (!raw) return false; @@ -34,13 +35,16 @@ export function loadShellEnvFallback( const logger = opts.logger ?? console; const exec = opts.exec ?? execFileSync; - if (!opts.enabled) + if (!opts.enabled) { + lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "disabled" }; + } const hasAnyKey = opts.expectedKeys.some((key) => Boolean(opts.env[key]?.trim()), ); if (hasAnyKey) { + lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "already-has-keys" }; } @@ -63,6 +67,7 @@ export function loadShellEnvFallback( } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn(`[clawdbot] shell env fallback failed: ${msg}`); + lastAppliedKeys = []; return { ok: false, error: msg, applied: [] }; } @@ -87,6 +92,7 @@ export function loadShellEnvFallback( applied.push(key); } + lastAppliedKeys = applied; return { ok: true, applied }; } @@ -103,3 +109,7 @@ export function resolveShellEnvFallbackTimeoutMs( if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS; return Math.max(0, parsed); } + +export function getShellEnvAppliedKeys(): string[] { + return [...lastAppliedKeys]; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 38e126e9b..b4f30e6a4 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -2,17 +2,17 @@ import path from "node:path"; import { loginAnthropic, + loginOpenAICodex, type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; -import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; import { isRemoteEnvironment, loginAntigravityVpsAware, } from "../commands/antigravity-oauth.js"; import { healthCommand } from "../commands/health.js"; import { + applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, writeOAuthCredentials, @@ -227,6 +227,11 @@ export async function runOnboardingWizard( spin.stop("OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("anthropic", oauthCreds); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "oauth", + }); } } catch (err) { spin.stop("OAuth failed"); @@ -250,10 +255,7 @@ export async function runOnboardingWizard( ); const spin = prompter.progress("Starting OAuth flow…"); try { - const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - const provider = "openai-codex" as unknown as OAuthProvider; - await authStorage.login(provider, { + const creds = await loginOpenAICodex({ onAuth: async ({ url }) => { if (isRemote) { spin.stop("OAuth URL ready"); @@ -275,6 +277,17 @@ export async function runOnboardingWizard( onProgress: (msg) => spin.update(msg), }); 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) { spin.stop("OpenAI OAuth failed"); runtime.error(String(err)); @@ -314,11 +327,29 @@ export async function runOnboardingWizard( spin.stop("Antigravity OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("google-antigravity", oauthCreds); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "google-antigravity:default", + provider: "google-antigravity", + mode: "oauth", + }); nextConfig = { ...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( @@ -336,6 +367,11 @@ export async function runOnboardingWizard( validate: (value) => (value?.trim() ? undefined : "Required"), }); await setAnthropicApiKey(String(key).trim()); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); }