feat!: redesign model config + auth profiles

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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