mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
feat!: redesign model config + auth profiles
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
### Breaking
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
|
||||
### Fixes
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
@@ -16,10 +17,10 @@
|
||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
|
||||
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
|
||||
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth-profiles.json.
|
||||
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
|
||||
- Model: `/model list` is an alias for `/model`.
|
||||
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
|
||||
- Model: `/model` output now includes auth source location (env/auth-profiles.json/models.json).
|
||||
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
|
||||
@@ -91,18 +91,40 @@ Env var equivalent:
|
||||
|
||||
### Auth storage (OAuth + API keys)
|
||||
|
||||
Clawdbot stores **OAuth credentials** in:
|
||||
Clawdbot stores **auth profiles** (OAuth + API keys) in:
|
||||
- `~/.clawdbot/agent/auth-profiles.json`
|
||||
|
||||
Legacy OAuth imports:
|
||||
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
|
||||
|
||||
Clawdbot stores **API keys** in the agent auth store:
|
||||
- `~/.clawdbot/agent/auth.json`
|
||||
The embedded Pi agent maintains a runtime cache at:
|
||||
- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually)
|
||||
|
||||
Overrides:
|
||||
- OAuth dir: `CLAWDBOT_OAUTH_DIR`
|
||||
- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
|
||||
- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
|
||||
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth.json` so the embedded
|
||||
agent can use them. `oauth.json` remains the source of truth for OAuth refresh.
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
|
||||
|
||||
### `auth`
|
||||
|
||||
Optional metadata for auth profiles. This does **not** store secrets; it maps
|
||||
profile IDs to a provider + mode (and optional email) and defines the provider
|
||||
rotation order used for failover.
|
||||
|
||||
```json5
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" }
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:default", "anthropic:work"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `identity`
|
||||
|
||||
@@ -494,14 +516,12 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V
|
||||
### `agent`
|
||||
|
||||
Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
|
||||
(omit to show the full catalog).
|
||||
`modelAliases` adds short names for `/model` (alias -> provider/model).
|
||||
`modelFallbacks` lists ordered fallback models to try when the default fails.
|
||||
`imageModel` selects an image-capable model for the `image` tool.
|
||||
`imageModelFallbacks` lists ordered fallback image models for the `image` tool.
|
||||
`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`).
|
||||
`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers.
|
||||
`agent.imageModel` is optional and is **only used if the primary model lacks image input**.
|
||||
|
||||
Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists):
|
||||
Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
|
||||
is already present in `agent.models`:
|
||||
|
||||
- `opus` -> `anthropic/claude-opus-4-5`
|
||||
- `sonnet` -> `anthropic/claude-sonnet-4-5`
|
||||
@@ -515,23 +535,24 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-1"
|
||||
],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Sonnet: "anthropic/claude-sonnet-4-1"
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
"openrouter/deepseek/deepseek-r1:free": {}
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
]
|
||||
},
|
||||
imageModel: {
|
||||
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
fallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
]
|
||||
},
|
||||
modelFallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
],
|
||||
imageModel: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
imageModelFallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
],
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
elevatedDefault: "on",
|
||||
@@ -566,8 +587,8 @@ Block streaming:
|
||||
}
|
||||
```
|
||||
|
||||
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`).
|
||||
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
||||
deprecation fallback.
|
||||
Z.AI models are available as `zai/<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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
26
docs/faq.md
26
docs/faq.md
@@ -15,7 +15,8 @@ Everything lives under `~/.clawdbot/`:
|
||||
|------|---------|
|
||||
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
|
||||
| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) |
|
||||
| `~/.clawdbot/agent/auth.json` | API key store |
|
||||
| `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
|
||||
| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) |
|
||||
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
|
||||
| `~/.clawdbot/sessions/` | Conversation history & state |
|
||||
| `~/.clawdbot/sessions/sessions.json` | Session metadata |
|
||||
@@ -576,21 +577,16 @@ List available models with `/model`, `/model list`, or `/model status`.
|
||||
Clawdbot ships a few default model shorthands (you can override them in config):
|
||||
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
|
||||
|
||||
**Setup:** Configure allowed models and aliases in `clawdbot.json`:
|
||||
**Setup:** Configure models and aliases in `clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"allowedModels": [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5"
|
||||
],
|
||||
"modelAliases": {
|
||||
"opus": "anthropic/claude-opus-4-5",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5"
|
||||
"model": { "primary": "anthropic/claude-opus-4-5" },
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": { "alias": "opus" },
|
||||
"anthropic/claude-sonnet-4-5": { "alias": "sonnet" },
|
||||
"anthropic/claude-haiku-4-5": { "alias": "haiku" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,7 +602,8 @@ If you don't want to use Anthropic directly, you can use alternative providers:
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "openrouter/anthropic/claude-sonnet-4",
|
||||
model: { primary: "openrouter/anthropic/claude-sonnet-4" },
|
||||
models: { "openrouter/anthropic/claude-sonnet-4": {} },
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." }
|
||||
}
|
||||
}
|
||||
@@ -616,7 +613,8 @@ If you don't want to use Anthropic directly, you can use alternative providers:
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "zai/glm-4.7",
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
env: { ZAI_API_KEY: "..." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,7 @@ The macOS app should:
|
||||
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
|
||||
|
||||
Why this location matters: it’s the Clawdbot-owned OAuth store.
|
||||
Clawdbot also imports `oauth.json` into the agent auth store (`~/.clawdbot/agent/auth.json`) on first use.
|
||||
Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use.
|
||||
|
||||
### Recommended: OAuth (OpenAI Codex)
|
||||
|
||||
@@ -148,7 +148,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored
|
||||
|
||||
For now, remote onboarding should:
|
||||
- explain why OAuth isn't shown
|
||||
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host
|
||||
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host
|
||||
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
|
||||
|
||||
### Manual credential setup
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -52,7 +52,7 @@ It does **not** install or change anything on the remote host.
|
||||
- **API key**: stores the key for you.
|
||||
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||
- **Skip**: no auth configured yet.
|
||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; API keys live in `~/.clawdbot/agent/auth.json`.
|
||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth).
|
||||
|
||||
3) **Workspace**
|
||||
- Default `~/clawd` (configurable).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
42
src/agents/auth-profiles.test.ts
Normal file
42
src/agents/auth-profiles.test.ts
Normal 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
314
src/agents/auth-profiles.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: ["*"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ export type FollowupRun = {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider: string;
|
||||
model: string;
|
||||
authProfileId?: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] ?? {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (0–5).",
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export type SessionEntry = {
|
||||
elevatedLevel?: string;
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
authProfileOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user