feat: add shared model picker to configure/onboarding
This commit is contained in:
committed by
Peter Steinberger
parent
e3cd431551
commit
dcc41e932d
@@ -83,6 +83,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
- **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint.
|
- **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint.
|
||||||
- **MiniMax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
- **MiniMax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||||
- **Skip**: no auth configured yet.
|
- **Skip**: no auth configured yet.
|
||||||
|
- Pick a default model from detected options (or enter provider/model manually).
|
||||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
||||||
- More detail: [/concepts/oauth](/concepts/oauth)
|
- More detail: [/concepts/oauth](/concepts/oauth)
|
||||||
|
|||||||
@@ -729,3 +729,32 @@ export async function applyAuthChoice(params: {
|
|||||||
|
|
||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePreferredProviderForAuthChoice(
|
||||||
|
choice: AuthChoice,
|
||||||
|
): string | undefined {
|
||||||
|
switch (choice) {
|
||||||
|
case "oauth":
|
||||||
|
case "setup-token":
|
||||||
|
case "claude-cli":
|
||||||
|
case "token":
|
||||||
|
case "apiKey":
|
||||||
|
return "anthropic";
|
||||||
|
case "openai-codex":
|
||||||
|
case "codex-cli":
|
||||||
|
return "openai-codex";
|
||||||
|
case "openai-api-key":
|
||||||
|
return "openai";
|
||||||
|
case "gemini-api-key":
|
||||||
|
return "google";
|
||||||
|
case "antigravity":
|
||||||
|
return "google-antigravity";
|
||||||
|
case "minimax-cloud":
|
||||||
|
case "minimax-api":
|
||||||
|
return "minimax";
|
||||||
|
case "minimax":
|
||||||
|
return "lmstudio";
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ import {
|
|||||||
WizardCancelledError,
|
WizardCancelledError,
|
||||||
type WizardPrompter,
|
type WizardPrompter,
|
||||||
} from "../wizard/prompts.js";
|
} from "../wizard/prompts.js";
|
||||||
import { applyAuthChoice } from "./auth-choice.js";
|
import {
|
||||||
|
applyAuthChoice,
|
||||||
|
resolvePreferredProviderForAuthChoice,
|
||||||
|
} from "./auth-choice.js";
|
||||||
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
@@ -46,6 +49,7 @@ import {
|
|||||||
} from "./daemon-runtime.js";
|
} from "./daemon-runtime.js";
|
||||||
import { healthCommand } from "./health.js";
|
import { healthCommand } from "./health.js";
|
||||||
import { formatHealthCheckFailure } from "./health-format.js";
|
import { formatHealthCheckFailure } from "./health-format.js";
|
||||||
|
import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js";
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
DEFAULT_WORKSPACE,
|
DEFAULT_WORKSPACE,
|
||||||
@@ -310,56 +314,15 @@ async function promptAuthConfig(
|
|||||||
next = applied.config;
|
next = applied.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentModel =
|
const modelSelection = await promptDefaultModel({
|
||||||
typeof next.agents?.defaults?.model === "string"
|
config: next,
|
||||||
? next.agents?.defaults?.model
|
prompter,
|
||||||
: (next.agents?.defaults?.model?.primary ?? "");
|
allowKeep: true,
|
||||||
const preferAnthropic =
|
ignoreAllowlist: true,
|
||||||
authChoice === "claude-cli" ||
|
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
|
||||||
authChoice === "setup-token" ||
|
});
|
||||||
authChoice === "token" ||
|
if (modelSelection.model) {
|
||||||
authChoice === "oauth" ||
|
next = applyPrimaryModel(next, modelSelection.model);
|
||||||
authChoice === "apiKey";
|
|
||||||
const modelInitialValue =
|
|
||||||
preferAnthropic && !currentModel.startsWith("anthropic/")
|
|
||||||
? "anthropic/claude-opus-4-5"
|
|
||||||
: currentModel;
|
|
||||||
|
|
||||||
const modelInput = guardCancel(
|
|
||||||
await text({
|
|
||||||
message: "Default model (blank to keep)",
|
|
||||||
initialValue: modelInitialValue,
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
const model = String(modelInput ?? "").trim();
|
|
||||||
if (model) {
|
|
||||||
const existingDefaults = next.agents?.defaults;
|
|
||||||
const existingModel = existingDefaults?.model;
|
|
||||||
const existingModels = existingDefaults?.models;
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
agents: {
|
|
||||||
...next.agents,
|
|
||||||
defaults: {
|
|
||||||
...existingDefaults,
|
|
||||||
model: {
|
|
||||||
...(existingModel &&
|
|
||||||
"fallbacks" in (existingModel as Record<string, unknown>)
|
|
||||||
? {
|
|
||||||
fallbacks: (existingModel as { fallbacks?: string[] })
|
|
||||||
.fallbacks,
|
|
||||||
}
|
|
||||||
: undefined),
|
|
||||||
primary: model,
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
...existingModels,
|
|
||||||
[model]: existingModels?.[model] ?? {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
282
src/commands/model-picker.ts
Normal file
282
src/commands/model-picker.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import {
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
listProfilesForProvider,
|
||||||
|
} from "../agents/auth-profiles.js";
|
||||||
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
|
import {
|
||||||
|
getCustomProviderApiKey,
|
||||||
|
resolveEnvApiKey,
|
||||||
|
} from "../agents/model-auth.js";
|
||||||
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
|
import {
|
||||||
|
buildAllowedModelSet,
|
||||||
|
buildModelAliasIndex,
|
||||||
|
modelKey,
|
||||||
|
normalizeProviderId,
|
||||||
|
resolveConfiguredModelRef,
|
||||||
|
} from "../agents/model-selection.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
|
||||||
|
import { formatTokenK } from "./models/shared.js";
|
||||||
|
|
||||||
|
const KEEP_VALUE = "__keep__";
|
||||||
|
const MANUAL_VALUE = "__manual__";
|
||||||
|
const PROVIDER_FILTER_THRESHOLD = 30;
|
||||||
|
|
||||||
|
type PromptDefaultModelParams = {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
allowKeep?: boolean;
|
||||||
|
includeManual?: boolean;
|
||||||
|
ignoreAllowlist?: boolean;
|
||||||
|
preferredProvider?: string;
|
||||||
|
agentDir?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromptDefaultModelResult = { model?: string };
|
||||||
|
|
||||||
|
function hasAuthForProvider(
|
||||||
|
provider: string,
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
store: ReturnType<typeof ensureAuthProfileStore>,
|
||||||
|
) {
|
||||||
|
if (listProfilesForProvider(store, provider).length > 0) return true;
|
||||||
|
if (resolveEnvApiKey(provider)) return true;
|
||||||
|
if (getCustomProviderApiKey(cfg, provider)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredModelRaw(cfg: ClawdbotConfig): string {
|
||||||
|
const raw = cfg.agents?.defaults?.model as
|
||||||
|
| { primary?: string }
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (typeof raw === "string") return raw.trim();
|
||||||
|
return raw?.primary?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptManualModel(params: {
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
allowBlank: boolean;
|
||||||
|
initialValue?: string;
|
||||||
|
}): Promise<PromptDefaultModelResult> {
|
||||||
|
const modelInput = await params.prompter.text({
|
||||||
|
message: params.allowBlank
|
||||||
|
? "Default model (blank to keep)"
|
||||||
|
: "Default model",
|
||||||
|
initialValue: params.initialValue,
|
||||||
|
placeholder: "provider/model",
|
||||||
|
validate: params.allowBlank
|
||||||
|
? undefined
|
||||||
|
: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
const model = String(modelInput ?? "").trim();
|
||||||
|
if (!model) return {};
|
||||||
|
return { model };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptDefaultModel(
|
||||||
|
params: PromptDefaultModelParams,
|
||||||
|
): Promise<PromptDefaultModelResult> {
|
||||||
|
const cfg = params.config;
|
||||||
|
const allowKeep = params.allowKeep ?? true;
|
||||||
|
const includeManual = params.includeManual ?? true;
|
||||||
|
const ignoreAllowlist = params.ignoreAllowlist ?? false;
|
||||||
|
const preferredProviderRaw = params.preferredProvider?.trim();
|
||||||
|
const preferredProvider = preferredProviderRaw
|
||||||
|
? normalizeProviderId(preferredProviderRaw)
|
||||||
|
: undefined;
|
||||||
|
const configuredRaw = resolveConfiguredModelRaw(cfg);
|
||||||
|
|
||||||
|
const resolved = resolveConfiguredModelRef({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
|
const resolvedKey = modelKey(resolved.provider, resolved.model);
|
||||||
|
const configuredKey = configuredRaw ? resolvedKey : "";
|
||||||
|
|
||||||
|
const catalog = await loadModelCatalog({ config: cfg });
|
||||||
|
if (catalog.length === 0) {
|
||||||
|
return promptManualModel({
|
||||||
|
prompter: params.prompter,
|
||||||
|
allowBlank: allowKeep,
|
||||||
|
initialValue: configuredRaw || resolvedKey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliasIndex = buildModelAliasIndex({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
let models = catalog;
|
||||||
|
if (!ignoreAllowlist) {
|
||||||
|
const { allowedCatalog } = buildAllowedModelSet({
|
||||||
|
cfg,
|
||||||
|
catalog,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
models = allowedCatalog.length > 0 ? allowedCatalog : catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
return promptManualModel({
|
||||||
|
prompter: params.prompter,
|
||||||
|
allowBlank: allowKeep,
|
||||||
|
initialValue: configuredRaw || resolvedKey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = Array.from(
|
||||||
|
new Set(models.map((entry) => entry.provider)),
|
||||||
|
).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const hasPreferredProvider = preferredProvider
|
||||||
|
? providers.includes(preferredProvider)
|
||||||
|
: false;
|
||||||
|
const shouldPromptProvider =
|
||||||
|
!hasPreferredProvider &&
|
||||||
|
providers.length > 1 &&
|
||||||
|
models.length > PROVIDER_FILTER_THRESHOLD;
|
||||||
|
if (shouldPromptProvider) {
|
||||||
|
const selection = await params.prompter.select({
|
||||||
|
message: "Filter models by provider",
|
||||||
|
options: [
|
||||||
|
{ value: "*", label: "All providers" },
|
||||||
|
...providers.map((provider) => {
|
||||||
|
const count = models.filter(
|
||||||
|
(entry) => entry.provider === provider,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
value: provider,
|
||||||
|
label: provider,
|
||||||
|
hint: `${count} model${count === 1 ? "" : "s"}`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (selection !== "*") {
|
||||||
|
models = models.filter((entry) => entry.provider === selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPreferredProvider && preferredProvider) {
|
||||||
|
models = models.filter((entry) => entry.provider === preferredProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||||
|
allowKeychainPrompt: false,
|
||||||
|
});
|
||||||
|
const authCache = new Map<string, boolean>();
|
||||||
|
const hasAuth = (provider: string) => {
|
||||||
|
const cached = authCache.get(provider);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const value = hasAuthForProvider(provider, cfg, authStore);
|
||||||
|
authCache.set(provider, value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: WizardSelectOption<string>[] = [];
|
||||||
|
if (allowKeep) {
|
||||||
|
options.push({
|
||||||
|
value: KEEP_VALUE,
|
||||||
|
label: configuredRaw
|
||||||
|
? `Keep current (${configuredRaw})`
|
||||||
|
: `Keep current (default: ${resolvedKey})`,
|
||||||
|
hint:
|
||||||
|
configuredRaw && configuredRaw !== resolvedKey
|
||||||
|
? `resolves to ${resolvedKey}`
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (includeManual) {
|
||||||
|
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const addModelOption = (entry: {
|
||||||
|
provider: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
reasoning?: boolean;
|
||||||
|
}) => {
|
||||||
|
const key = modelKey(entry.provider, entry.id);
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
const hints: string[] = [];
|
||||||
|
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
|
||||||
|
if (entry.contextWindow)
|
||||||
|
hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
|
||||||
|
if (entry.reasoning) hints.push("reasoning");
|
||||||
|
const aliases = aliasIndex.byKey.get(key);
|
||||||
|
if (aliases?.length) hints.push(`alias: ${aliases.join(", ")}`);
|
||||||
|
if (!hasAuth(entry.provider)) hints.push("auth missing");
|
||||||
|
options.push({
|
||||||
|
value: key,
|
||||||
|
label: key,
|
||||||
|
hint: hints.length > 0 ? hints.join(" · ") : undefined,
|
||||||
|
});
|
||||||
|
seen.add(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of models) addModelOption(entry);
|
||||||
|
|
||||||
|
if (configuredKey && !seen.has(configuredKey)) {
|
||||||
|
options.push({
|
||||||
|
value: configuredKey,
|
||||||
|
label: configuredKey,
|
||||||
|
hint: "current (not in catalog)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = allowKeep ? KEEP_VALUE : configuredKey || undefined;
|
||||||
|
|
||||||
|
const selection = await params.prompter.select({
|
||||||
|
message: params.message ?? "Default model",
|
||||||
|
options,
|
||||||
|
initialValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selection === KEEP_VALUE) return {};
|
||||||
|
if (selection === MANUAL_VALUE) {
|
||||||
|
return promptManualModel({
|
||||||
|
prompter: params.prompter,
|
||||||
|
allowBlank: false,
|
||||||
|
initialValue: configuredRaw || resolvedKey || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { model: String(selection) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrimaryModel(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
model: string,
|
||||||
|
): ClawdbotConfig {
|
||||||
|
const defaults = cfg.agents?.defaults;
|
||||||
|
const existingModel = defaults?.model;
|
||||||
|
const existingModels = defaults?.models;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
agents: {
|
||||||
|
...cfg.agents,
|
||||||
|
defaults: {
|
||||||
|
...defaults,
|
||||||
|
model: {
|
||||||
|
...(existingModel &&
|
||||||
|
"fallbacks" in (existingModel as Record<string, unknown>)
|
||||||
|
? {
|
||||||
|
fallbacks: (existingModel as { fallbacks?: string[] })
|
||||||
|
.fallbacks,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
primary: model,
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
...existingModels,
|
||||||
|
[model]: existingModels?.[model] ?? {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
|||||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
|
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
|
||||||
import {
|
import {
|
||||||
applyAuthChoice,
|
applyAuthChoice,
|
||||||
|
resolvePreferredProviderForAuthChoice,
|
||||||
warnIfModelConfigLooksOff,
|
warnIfModelConfigLooksOff,
|
||||||
} from "../commands/auth-choice.js";
|
} from "../commands/auth-choice.js";
|
||||||
import { buildAuthChoiceOptions } from "../commands/auth-choice-options.js";
|
import { buildAuthChoiceOptions } from "../commands/auth-choice-options.js";
|
||||||
@@ -14,6 +15,10 @@ import {
|
|||||||
} from "../commands/daemon-runtime.js";
|
} from "../commands/daemon-runtime.js";
|
||||||
import { healthCommand } from "../commands/health.js";
|
import { healthCommand } from "../commands/health.js";
|
||||||
import { formatHealthCheckFailure } from "../commands/health-format.js";
|
import { formatHealthCheckFailure } from "../commands/health-format.js";
|
||||||
|
import {
|
||||||
|
applyPrimaryModel,
|
||||||
|
promptDefaultModel,
|
||||||
|
} from "../commands/model-picker.js";
|
||||||
import {
|
import {
|
||||||
applyWizardMetadata,
|
applyWizardMetadata,
|
||||||
DEFAULT_WORKSPACE,
|
DEFAULT_WORKSPACE,
|
||||||
@@ -343,6 +348,17 @@ export async function runOnboardingWizard(
|
|||||||
});
|
});
|
||||||
nextConfig = authResult.config;
|
nextConfig = authResult.config;
|
||||||
|
|
||||||
|
const modelSelection = await promptDefaultModel({
|
||||||
|
config: nextConfig,
|
||||||
|
prompter,
|
||||||
|
allowKeep: true,
|
||||||
|
ignoreAllowlist: true,
|
||||||
|
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
|
||||||
|
});
|
||||||
|
if (modelSelection.model) {
|
||||||
|
nextConfig = applyPrimaryModel(nextConfig, modelSelection.model);
|
||||||
|
}
|
||||||
|
|
||||||
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
await warnIfModelConfigLooksOff(nextConfig, prompter);
|
||||||
|
|
||||||
const port =
|
const port =
|
||||||
|
|||||||
Reference in New Issue
Block a user