feat!: redesign model config + auth profiles

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

View File

@@ -6,6 +6,7 @@
### Breaking
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
### Fixes
- 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.

View File

@@ -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; dont 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/<model>` (e.g. `zai/glm-4.7`) and require
@@ -729,11 +750,16 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into
- default behavior: **merge** (keeps existing providers, overrides on name)
- 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: {

View File

@@ -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

View File

@@ -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: "..." }
}
}

View File

@@ -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 <name>`, `--json`, `--plain`
- `clawdbot models status`
- show default model + aliases + fallbacks + allowlist
- show default model + aliases + fallbacks + configured models
- `clawdbot models set <modelOrAlias>`
- writes `agent.model` in config
- writes `agent.model.primary` and ensures `agent.models` entry
- `clawdbot models set-image <modelOrAlias>`
- writes `agent.imageModel` in config
- writes `agent.imageModel.primary` and ensures `agent.models` entry
- `clawdbot models aliases list|add|remove`
- 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.

View File

@@ -41,7 +41,7 @@ The macOS app should:
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: its the Clawdbot-owned OAuth store.
Clawdbot also imports `oauth.json` into the agent auth store (`~/.clawdbot/agent/auth.json`) on first use.
Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use.
### Recommended: OAuth (OpenAI Codex)
@@ -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

View File

@@ -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.

View File

@@ -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`

View File

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

View File

@@ -52,7 +52,7 @@ It does **not** install or change anything on the remote host.
- **API key**: stores the key for you.
- **Minimax M2.1 (LM Studio)**: config is autowritten 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).

View File

@@ -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);
}

View File

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

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

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

View File

@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import 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<Api>;
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<string, unknown>;
expect(authData["openai-codex"]).toMatchObject({
type: "oauth",
access: oauthFixture.access,
refresh: oauthFixture.refresh,
const authData = JSON.parse(authProfiles) as Record<string, unknown>;
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 });
}
});

View File

@@ -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<string, OAuthCredentials>;
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<typeof discoverAuthStorage>,
): void {
if (oauthStorageMigrated) return;
oauthStorageMigrated = true;
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (!storage) return;
for (const [provider, creds] of Object.entries(storage)) {
if (!isValidOAuthCredential(creds)) continue;
if (authStorage.get(provider)) continue;
authStorage.set(provider, { type: "oauth", ...creds });
}
}
export function hydrateAuthStorage(
authStorage: ReturnType<typeof discoverAuthStorage>,
): void {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
}
function isOAuthProvider(provider: string): provider is OAuthProvider {
return (
provider === "anthropic" ||
provider === "anthropic-oauth" ||
provider === "google" ||
provider === "openai" ||
provider === "openai-compatible" ||
provider === "openai-codex" ||
provider === "github-copilot" ||
provider === "google-gemini-cli" ||
provider === "google-antigravity"
);
}
export async function getApiKeyForModel(
model: Model<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
ensureOAuthStorage();
migrateOAuthStorageToAuthStorage(authStorage);
const storedKey = await authStorage.getApiKey(model.provider);
if (storedKey) return storedKey;
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
const envKey = getEnvApiKey(model.provider);
if (envKey) return envKey;
if (isOAuthProvider(model.provider)) {
const oauthPath = resolveClawdbotOAuthPath();
const storage = loadOAuthStorageAt(oauthPath);
if (storage) {
try {
const result = await getOAuthApiKey(model.provider, storage);
if (result?.apiKey) {
storage[model.provider] = result.newCredentials;
saveOAuthStorageAt(oauthPath, storage);
return result.apiKey;
}
} catch {
// fall through to error below
}
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<string, string> = {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};
const envVar = envMap[provider];
if (!envVar) return null;
return pick(envVar);
}
export async function getApiKeyForModel(params: {
model: Model<Api>;
cfg?: ClawdbotConfig;
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
return resolveApiKeyForProvider({
provider: params.model.provider,
cfg: params.cfg,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
store: params.store,
});
}

View File

@@ -33,7 +33,10 @@ function buildAllowedModelKeys(
cfg: ClawdbotConfig | undefined,
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
const keys = new Set<string>();
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<T>(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.",
);
}

View File

@@ -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" });
});
});

View File

@@ -41,18 +41,17 @@ export function buildModelAliasIndex(params: {
cfg: ClawdbotConfig;
defaultProvider: string;
}): ModelAliasIndex {
const rawAliases = params.cfg.agent?.modelAliases ?? {};
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
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<string>;
} {
const rawAllowlist = params.cfg.agent?.allowedModels ?? [];
const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
const catalogKeys = new Set(
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),

View File

@@ -120,12 +120,40 @@ export function isRateLimitAssistantError(
if (!msg || msg.stopReason !== "error") return false;
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) ??

View File

@@ -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<ThinkLevel>();
let apiKeyInfo: Awaited<ReturnType<typeof getApiKeyForModel>> | null =
null;
const resolveApiKeyForCandidate = async (candidate?: string) => {
return getApiKeyForModel({
model,
cfg: params.config,
profileId: candidate,
store: authStore,
});
};
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
};
const advanceAuthProfile = async (): Promise<boolean> => {
let nextIndex = profileIndex + 1;
while (nextIndex < profileCandidates.length) {
const candidate = profileCandidates[nextIndex];
try {
await applyApiKeyInfo(candidate);
profileIndex = nextIndex;
thinkLevel = initialThinkLevel;
attemptedThinking.clear();
return true;
} catch (err) {
if (candidate && candidate === explicitProfileId) throw err;
nextIndex += 1;
}
}
return false;
};
try {
await applyApiKeyInfo(profileCandidates[profileIndex]);
} catch (err) {
if (profileCandidates[profileIndex] === explicitProfileId) throw err;
const advanced = await advanceAuthProfile();
if (!advanced) throw err;
}
while (true) {
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: {

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -37,11 +37,24 @@ vi.mock("../agents/model-catalog.js", () => ({
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
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: ["*"],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, unknown>;
const entry = parsed?.[provider] as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access =
entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
} catch {
return false;
}
}
function resolveModelAuthLabel(provider?: string): string | undefined {
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,

View File

@@ -1,13 +1,20 @@
import { getEnvApiKey } from "@mariozechner/pi-ai";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
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<typeof discoverAuthStorage>,
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<string, SessionEntry>;
@@ -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<string, string>();
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<string, SessionEntry>;
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}`;

View File

@@ -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,

View File

@@ -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;

View File

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

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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] ?? {},
},
},
};
}

View File

@@ -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<Record<string, string>>(
(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.");
}

View File

@@ -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: [],
},
},
}));

View File

@@ -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: [],
},
},
}));

View File

@@ -1,4 +1,4 @@
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
import type { Api, Model } from "@mariozechner/pi-ai";
import {
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<string>;
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<Api>[] = [];
@@ -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<string, string>
>((acc, [key, entry]) => {
const alias = entry?.alias?.trim();
if (alias) acc[alias] = key;
return acc;
}, {});
const allowed = Object.keys(cfg.agent?.models ?? {});
if (opts.json) {
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"
}`,
);
}

View File

@@ -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<typeof cfg.agent>;
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) {

View File

@@ -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}`);
}

View File

@@ -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}`);
}

View File

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

View File

@@ -5,11 +5,12 @@ import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { 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<string, OAuthCredentials>;
expect(parsed.anthropic).toMatchObject({
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, OAuthCredentials & { type?: string }>;
};
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",
});
});
});

View File

@@ -1,47 +1,73 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
import { 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<void> {
const filePath = resolveOAuthPath();
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
let storage: Record<string, OAuthCredentials> = {};
try {
const raw = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, OAuthCredentials>;
if (parsed && typeof parsed === "object") storage = parsed;
} catch {
// ignore
}
storage[provider] = creds;
await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8");
await fs.chmod(filePath, 0o600);
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",

View File

@@ -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}`);

View File

@@ -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 (

View File

@@ -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,
},
}),
};
});

View File

@@ -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");

View File

@@ -628,6 +628,18 @@ describe("legacy config detection", () => {
}
});
it("rejects legacy agent.model string", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agent: { model: "anthropic/claude-opus-4-5" },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agent.model");
}
});
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
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");

View File

@@ -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<string, string>();
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<string, string> = { ...existingAliases };
const nextModels: Record<string, { alias?: string }> = {
...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,
},
};
}

View File

@@ -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");

View File

@@ -3,6 +3,7 @@ import type { LegacyConfigIssue } from "./types.js";
type LegacyConfigRule = {
path: string[];
message: string;
match?: (value: unknown, root: Record<string, unknown>) => 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<string, unknown>)
: null;
if (!agent) return;
const legacyModel =
typeof agent.model === "string" ? String(agent.model) : undefined;
const legacyImageModel =
typeof agent.imageModel === "string"
? String(agent.imageModel)
: undefined;
const legacyAllowed = Array.isArray(agent.allowedModels)
? (agent.allowedModels as unknown[]).map(String)
: [];
const legacyModelFallbacks = Array.isArray(agent.modelFallbacks)
? (agent.modelFallbacks as unknown[]).map(String)
: [];
const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks)
? (agent.imageModelFallbacks as unknown[]).map(String)
: [];
const legacyAliases =
agent.modelAliases && typeof agent.modelAliases === "object"
? (agent.modelAliases as Record<string, unknown>)
: {};
const hasLegacy =
legacyModel ||
legacyImageModel ||
legacyAllowed.length > 0 ||
legacyModelFallbacks.length > 0 ||
legacyImageModelFallbacks.length > 0 ||
Object.keys(legacyAliases).length > 0;
if (!hasLegacy) return;
const models =
agent.models && typeof agent.models === "object"
? (agent.models as Record<string, unknown>)
: {};
const ensureModel = (rawKey?: string) => {
const key = String(rawKey ?? "").trim();
if (!key) return;
if (!models[key]) models[key] = {};
};
ensureModel(legacyModel);
ensureModel(legacyImageModel);
for (const key of legacyAllowed) ensureModel(key);
for (const key of legacyModelFallbacks) ensureModel(key);
for (const key of legacyImageModelFallbacks) ensureModel(key);
for (const target of Object.values(legacyAliases)) {
ensureModel(String(target ?? ""));
}
for (const [alias, targetRaw] of Object.entries(legacyAliases)) {
const target = String(targetRaw ?? "").trim();
if (!target) continue;
const entry =
models[target] && typeof models[target] === "object"
? (models[target] as Record<string, unknown>)
: {};
if (!("alias" in entry)) {
entry.alias = alias;
models[target] = entry;
}
}
const currentModel =
agent.model && typeof agent.model === "object"
? (agent.model as Record<string, unknown>)
: null;
if (currentModel) {
if (!currentModel.primary && legacyModel) {
currentModel.primary = legacyModel;
}
if (
legacyModelFallbacks.length > 0 &&
(!Array.isArray(currentModel.fallbacks) ||
currentModel.fallbacks.length === 0)
) {
currentModel.fallbacks = legacyModelFallbacks;
}
agent.model = currentModel;
} else if (legacyModel || legacyModelFallbacks.length > 0) {
agent.model = {
primary: legacyModel,
fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [],
};
}
const currentImageModel =
agent.imageModel && typeof agent.imageModel === "object"
? (agent.imageModel as Record<string, unknown>)
: null;
if (currentImageModel) {
if (!currentImageModel.primary && legacyImageModel) {
currentImageModel.primary = legacyImageModel;
}
if (
legacyImageModelFallbacks.length > 0 &&
(!Array.isArray(currentImageModel.fallbacks) ||
currentImageModel.fallbacks.length === 0)
) {
currentImageModel.fallbacks = legacyImageModelFallbacks;
}
agent.imageModel = currentImageModel;
} else if (legacyImageModel || legacyImageModelFallbacks.length > 0) {
agent.imageModel = {
primary: legacyImageModel,
fallbacks: legacyImageModelFallbacks.length
? legacyImageModelFallbacks
: [],
};
}
agent.models = models;
if (legacyModel !== undefined) {
changes.push("Migrated agent.model string → agent.model.primary.");
}
if (legacyModelFallbacks.length > 0) {
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
}
if (legacyImageModel !== undefined) {
changes.push(
"Migrated agent.imageModel string → agent.imageModel.primary.",
);
}
if (legacyImageModelFallbacks.length > 0) {
changes.push(
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
);
}
if (legacyAllowed.length > 0) {
changes.push("Migrated agent.allowedModels → agent.models.");
}
if (Object.keys(legacyAliases).length > 0) {
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
}
delete agent.allowedModels;
delete agent.modelAliases;
delete agent.modelFallbacks;
delete agent.imageModelFallbacks;
},
},
];
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
@@ -180,7 +365,7 @@ export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
}
cursor = (cursor as Record<string, unknown>)[key];
}
if (cursor !== undefined) {
if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) {
issues.push({ path: rule.path.join("."), message: rule.message });
}
}

View File

@@ -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");
});
});

View File

@@ -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();
/**

View File

@@ -87,10 +87,13 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
'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 (05).",
};

View File

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

View File

@@ -639,7 +639,28 @@ export type ModelsConfig = {
providers?: Record<string, ModelProviderConfig>;
};
export type AuthProfileConfig = {
provider: string;
mode: "api_key" | "oauth";
email?: string;
};
export type AuthConfig = {
profiles?: Record<string, AuthProfileConfig>;
order?: Record<string, string[]>;
};
export type AgentModelEntryConfig = {
alias?: string;
};
export type AgentModelListConfig = {
primary?: string;
fallbacks?: string[];
};
export type ClawdbotConfig = {
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<string, AgentModelEntryConfig>;
/** 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<string, string>;
/** Ordered fallback models (provider/model). */
modelFallbacks?: string[];
/** Ordered fallback image models (provider/model) for the image tool. */
imageModelFallbacks?: string[];
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Default thinking level when no /think directive is present. */

View File

@@ -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),
),

View File

@@ -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({

View File

@@ -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];
}

View File

@@ -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);
}