refactor: unify configure auth choice

This commit is contained in:
Peter Steinberger
2026-01-10 15:51:29 +01:00
parent d6d5c5ccd1
commit 53a0c966a5
5 changed files with 332 additions and 882 deletions

View File

@@ -1,57 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // Fail immediately on 429 for Antigravity to let callers rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && response.status === 429) {
+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
+ }
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644
--- a/dist/providers/openai-codex-responses.js
+++ b/dist/providers/openai-codex-responses.js
@@ -433,9 +433,15 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644
--- a/dist/providers/openai-responses.js
+++ b/dist/providers/openai-responses.js
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
}
else if (msg.role === "assistant") {
const output = [];
+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`.
+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but
+ // their stored reasoning items must not be replayed as standalone `reasoning` input.
+ const hasTextBlock = msg.content.some((b) => b.type === "text");
for (const block of msg.content) {
// Do not submit thinking blocks if the completion had an error (i.e. abort)
if (block.type === "thinking" && msg.stopReason !== "error") {
if (block.thinkingSignature) {
+ if (!hasTextBlock)
+ continue;
const reasoningItem = JSON.parse(block.thinkingSignature);
output.push(reasoningItem);
}

View File

@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.42.1': patches/@mariozechner__pi-ai@0.42.1.patch
'@mariozechner/pi-ai@0.42.2': patches/@mariozechner__pi-ai@0.42.2.patch

View File

@@ -8,21 +8,8 @@ import {
outro as clackOutro,
select as clackSelect,
text as clackText,
spinner,
} from "@clack/prompts";
import {
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { createCliProgress } from "../cli/progress.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
@@ -36,7 +23,6 @@ import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import { listChatProviders } from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -45,47 +31,26 @@ import {
stylePromptMessage,
stylePromptTitle,
} from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
WizardCancelledError,
type WizardPrompter,
} from "../wizard/prompts.js";
import { applyAuthChoice } from "./auth-choice.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
import {
buildTokenProfileId,
validateAnthropicSetupToken,
} from "./auth-token.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
} from "./google-gemini-model-default.js";
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
import {
applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxConfig,
applyMinimaxHostedConfig,
applyOpencodeZenConfig,
setAnthropicApiKey,
setGeminiApiKey,
setMinimaxApiKey,
setOpencodeZenApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
ensureWorkspaceAndSessions,
guardCancel,
openUrl,
printWizardHeader,
probeGatewayReachable,
randomToken,
@@ -95,11 +60,7 @@ import {
import { setupProviders } from "./onboard-providers.js";
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
import { setupSkills } from "./onboard-skills.js";
import {
applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL,
} from "./openai-codex-model-default.js";
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
import type { AuthChoice } from "./onboard-types.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
export const CONFIGURE_WIZARD_SECTIONS = [
@@ -158,27 +119,6 @@ const multiselect = <T>(params: Parameters<typeof clackMultiselect<T>>[0]) =>
),
});
const startOscSpinner = (label: string) => {
const spin = spinner();
spin.start(theme.accent(label));
const osc = createCliProgress({
label,
indeterminate: true,
enabled: true,
fallback: "none",
});
return {
update: (message: string) => {
spin.message(theme.accent(message));
osc.setLabel(message);
},
stop: (message: string) => {
osc.done();
spin.stop(message);
},
};
};
async function promptGatewayConfig(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
@@ -345,9 +285,9 @@ async function promptGatewayConfig(
async function promptAuthConfig(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<ClawdbotConfig> {
const authChoice = guardCancel(
await select({
const authChoice: AuthChoice = await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: ensureAuthProfileStore(undefined, {
@@ -356,481 +296,18 @@ async function promptAuthConfig(
includeSkip: true,
includeClaudeCliIfMissing: true,
}),
}),
runtime,
) as
| "oauth"
| "setup-token"
| "claude-cli"
| "token"
| "openai-codex"
| "openai-api-key"
| "codex-cli"
| "antigravity"
| "gemini-api-key"
| "apiKey"
| "minimax-cloud"
| "minimax-api"
| "minimax"
| "opencode-zen"
| "skip";
});
let next = cfg;
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) {
note(
[
"No Claude CLI credentials found yet.",
"If you have a Claude Pro/Max subscription, run `claude setup-token`.",
].join("\n"),
"Claude CLI",
);
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
if (authChoice !== "skip") {
const applied = await applyAuthChoice({
authChoice,
config: next,
prompter,
runtime,
);
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
}
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
setDefaultModel: true,
});
} else if (authChoice === "setup-token" || authChoice === "oauth") {
note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return next;
}
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (!runNow) return next;
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return next;
}
if (typeof res.status === "number" && res.status !== 0) {
note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return next;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return next;
}
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "token") {
const provider = guardCancel(
await select({
message: "Token provider",
options: [
{
value: "anthropic",
label: "Anthropic (only supported)",
},
],
}),
runtime,
) as "anthropic";
note(
[
"Run `claude setup-token` in your terminal.",
"Then paste the generated token below.",
].join("\n"),
"Anthropic token",
);
const tokenRaw = guardCancel(
await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
}),
runtime,
);
const token = String(tokenRaw).trim();
const profileNameRaw = guardCancel(
await text({
message: "Token name (blank = default)",
placeholder: "default",
}),
runtime,
);
const profileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),
});
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-api-key") {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = guardCancel(
await confirm({
message: `Use existing OPENAI_API_KEY (${envKey.source})?`,
initialValue: true,
}),
runtime,
);
if (useExisting) {
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: envKey.apiKey,
});
if (!process.env.OPENAI_API_KEY) {
process.env.OPENAI_API_KEY = envKey.apiKey;
}
note(
`Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
}
}
const key = guardCancel(
await text({
message: "Enter OpenAI API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
const trimmed = String(key).trim();
const result = upsertSharedEnvVar({
key: "OPENAI_API_KEY",
value: trimmed,
});
process.env.OPENAI_API_KEY = trimmed;
note(
`Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`,
"OpenAI API key",
);
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
const spin = startOscSpinner("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined;
try {
const creds = await loginOpenAICodex({
onAuth: async ({ url }) => {
if (isRemote) {
spin.update("OAuth URL ready (see below)…");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
manualCodePromise = text({
message: "Paste the redirect URL (or authorization code)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}).then((value) => String(guardCancel(value, runtime)));
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
onPrompt: async (prompt) => {
if (manualCodePromise) return manualCodePromise;
const code = guardCancel(
await text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
onProgress: (msg) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");
if (creds) {
await writeOAuthCredentials(
"openai-codex" as unknown as OAuthProvider,
creds,
);
next = applyAuthProfileConfig(next, {
profileId: "openai-codex:default",
provider: "openai-codex",
mode: "oauth",
});
const applied = applyOpenAICodexModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
}
} catch (err) {
spin.stop("OpenAI OAuth failed");
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "codex-cli") {
next = applyAuthProfileConfig(next, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
const applied = applyOpenAICodexModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = startOscSpinner("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.update(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
next = applyAuthProfileConfig(next, {
profileId: `google-antigravity:${oauthCreds.email ?? "default"}`,
provider: "google-antigravity",
mode: "oauth",
});
// Set default model to Claude Opus 4.5 via Antigravity
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: "google-antigravity/claude-opus-4-5-thinking",
},
models: {
...existingModels,
"google-antigravity/claude-opus-4-5-thinking":
existingModels?.[
"google-antigravity/claude-opus-4-5-thinking"
] ?? {},
},
},
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
}
} else if (authChoice === "gemini-api-key") {
const key = guardCancel(
await text({
message: "Enter Gemini API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setGeminiApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "google:default",
provider: "google",
mode: "api_key",
});
const applied = applyGoogleGeminiModelDefault(next);
next = applied.next;
if (applied.changed) {
note(
`Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`,
"Model configured",
);
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
message: "Enter Anthropic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setAnthropicApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "anthropic:default",
provider: "anthropic",
mode: "api_key",
});
} else if (authChoice === "minimax-cloud") {
const key = guardCancel(
await text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setMinimaxApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
next = applyMinimaxHostedConfig(next);
} else if (authChoice === "minimax") {
next = applyMinimaxConfig(next);
} else if (authChoice === "minimax-api") {
const key = guardCancel(
await text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setMinimaxApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
next = applyMinimaxApiConfig(next);
} else if (authChoice === "opencode-zen") {
note(
[
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
"Get your API key at: https://opencode.ai/auth",
].join("\n"),
"OpenCode Zen",
);
const key = guardCancel(
await text({
message: "Enter OpenCode Zen API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setOpencodeZenApiKey(String(key).trim());
next = applyAuthProfileConfig(next, {
profileId: "opencode-zen:default",
provider: "opencode-zen",
mode: "api_key",
});
next = applyOpencodeZenConfig(next);
note(
`Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
"Model configured",
);
next = applied.config;
}
const currentModel =
@@ -1051,9 +528,12 @@ export async function runConfigureWizard(
opts: ConfigureWizardParams,
runtime: RuntimeEnv = defaultRuntime,
) {
try {
printWizardHeader(runtime);
intro(
opts.command === "update" ? "Clawdbot update wizard" : "Clawdbot configure",
opts.command === "update"
? "Clawdbot update wizard"
: "Clawdbot configure",
);
const prompter = createClackPrompter();
@@ -1225,7 +705,7 @@ export async function runConfigureWizard(
}
if (selected.includes("model")) {
nextConfig = await promptAuthConfig(nextConfig, runtime);
nextConfig = await promptAuthConfig(nextConfig, runtime, prompter);
}
if (selected.includes("gateway")) {
@@ -1350,6 +830,13 @@ export async function runConfigureWizard(
);
outro("Configure complete.");
} catch (err) {
if (err instanceof WizardCancelledError) {
runtime.exit(0);
return;
}
throw err;
}
}
export async function configureCommand(runtime: RuntimeEnv = defaultRuntime) {

View File

@@ -177,7 +177,7 @@ describe("applyMinimaxApiConfig", () => {
).toMatchObject({ alias: "Minimax", params: { custom: "value" } });
});
it("replaces existing minimax provider entirely", () => {
it("merges existing minimax provider models", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
@@ -204,7 +204,11 @@ describe("applyMinimaxApiConfig", () => {
"https://api.minimax.io/anthropic",
);
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.minimax?.models[0]?.id).toBe("MiniMax-M2.1");
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([
"old-model",
"MiniMax-M2.1",
]);
});
it("preserves other providers when adding minimax", () => {

View File

@@ -334,11 +334,27 @@ export function applyMinimaxApiProviderConfig(
modelId: string = "MiniMax-M2.1",
): ClawdbotConfig {
const providers = { ...cfg.models?.providers };
const existingProvider = providers.minimax;
const existingModels = Array.isArray(existingProvider?.models)
? existingProvider.models
: [];
const apiModel = buildMinimaxApiModelDefinition(modelId);
const hasApiModel = existingModels.some((model) => model.id === modelId);
const mergedModels = hasApiModel
? existingModels
: [...existingModels, apiModel];
const { apiKey: existingApiKey, ...existingProviderRest } =
(existingProvider ?? {}) as Record<string, unknown> as { apiKey?: string };
const resolvedApiKey =
typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey =
resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
providers.minimax = {
...existingProviderRest,
baseUrl: MINIMAX_API_BASE_URL,
// apiKey omitted: resolved via MINIMAX_API_KEY env var or auth profile by default.
api: "anthropic-messages",
models: [buildMinimaxApiModelDefinition(modelId)],
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [apiModel],
};
const models = { ...cfg.agents?.defaults?.models };