From a27efd57bdca4eb488ee7af6574a8d11ab177bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 01:58:30 +0000 Subject: [PATCH] fix: drop null-only union variants (#782) (thanks @AbhisekBasu1) Co-authored-by: Abhi --- CHANGELOG.md | 1 + src/agents/models-config.ts | 199 ++++++++----- src/agents/pi-tools.test.ts | 5 +- src/agents/schema/clean-for-gemini.ts | 138 +++++---- src/commands/auth-choice.ts | 407 +++++++------------------- 5 files changed, 315 insertions(+), 435 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6084a3cf6..3632d3003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc) - Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc) - Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj) +- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1) ## 2026.1.12-3 diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b28ea7c51..28fc73723 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,63 +2,78 @@ import fs from "node:fs/promises"; import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; +import { + DEFAULT_COPILOT_API_BASE_URL, + resolveCopilotApiToken, +} from "../providers/github-copilot-token.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { ensureAuthProfileStore, listProfilesForProvider, } from "./auth-profiles.js"; -import { resolveEnvApiKey } from "./model-auth.js"; +import { + normalizeProviders, + type ProviderConfig, + resolveImplicitProviders, +} from "./models-config.providers.js"; type ModelsConfig = NonNullable; -type ProviderConfig = NonNullable[string]; const DEFAULT_MODE: NonNullable = "merge"; -const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1"; -const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; -const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs. -const MINIMAX_API_COST = { - input: 15, - output: 60, - cacheRead: 2, - cacheWrite: 10, -}; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function normalizeGoogleModelId(id: string): string { - if (id === "gemini-3-pro") return "gemini-3-pro-preview"; - if (id === "gemini-3-flash") return "gemini-3-flash-preview"; - return id; +function mergeProviderModels( + implicit: ProviderConfig, + explicit: ProviderConfig, +): ProviderConfig { + const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; + const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; + if (implicitModels.length === 0) return { ...implicit, ...explicit }; + + const getId = (model: unknown): string => { + if (!model || typeof model !== "object") return ""; + const id = (model as { id?: unknown }).id; + return typeof id === "string" ? id.trim() : ""; + }; + const seen = new Set(explicitModels.map(getId).filter(Boolean)); + + const mergedModels = [ + ...explicitModels, + ...implicitModels.filter((model) => { + const id = getId(model); + if (!id) return false; + if (seen.has(id)) return false; + seen.add(id); + return true; + }), + ]; + + return { + ...implicit, + ...explicit, + models: mergedModels, + }; } -function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig { - let mutated = false; - const models = provider.models.map((model) => { - const nextId = normalizeGoogleModelId(model.id); - if (nextId === model.id) return model; - mutated = true; - return { ...model, id: nextId }; - }); - return mutated ? { ...provider, models } : provider; -} - -function normalizeProviders( - providers: ModelsConfig["providers"], -): ModelsConfig["providers"] { - if (!providers) return providers; - let mutated = false; - const next: Record = {}; - for (const [key, provider] of Object.entries(providers)) { - const normalized = - key === "google" ? normalizeGoogleProvider(provider) : provider; - if (normalized !== provider) mutated = true; - next[key] = normalized; +function mergeProviders(params: { + implicit?: Record | null; + explicit?: Record | null; +}): Record { + const out: Record = params.implicit + ? { ...params.implicit } + : {}; + for (const [key, explicit] of Object.entries(params.explicit ?? {})) { + const providerKey = key.trim(); + if (!providerKey) continue; + const implicit = out[providerKey]; + out[providerKey] = implicit + ? mergeProviderModels(implicit, explicit) + : explicit; } - return mutated ? next : providers; + return out; } async function readJson(pathname: string): Promise { @@ -70,37 +85,62 @@ async function readJson(pathname: string): Promise { } } -function buildMinimaxApiProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_API_BASE_URL, - api: "anthropic-messages", - models: [ - { - id: MINIMAX_DEFAULT_MODEL_ID, - name: "MiniMax M2.1", - reasoning: false, - input: ["text"], - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -function resolveImplicitProviders(params: { - cfg: ClawdbotConfig; +async function maybeBuildCopilotProvider(params: { agentDir: string; -}): ModelsConfig["providers"] { - const providers: Record = {}; - const minimaxEnv = resolveEnvApiKey("minimax"); + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; const authStore = ensureAuthProfileStore(params.agentDir); - const hasMinimaxProfile = - listProfilesForProvider(authStore, "minimax").length > 0; - if (minimaxEnv || hasMinimaxProfile) { - providers.minimax = buildMinimaxApiProvider(); + const hasProfile = + listProfilesForProvider(authStore, "github-copilot").length > 0; + const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; + const githubToken = (envToken ?? "").trim(); + + if (!hasProfile && !githubToken) return null; + + let selectedGithubToken = githubToken; + if (!selectedGithubToken && hasProfile) { + // Use the first available profile as a default for discovery (it will be + // re-resolved per-run by the embedded runner). + const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; + const profile = profileId ? authStore.profiles[profileId] : undefined; + if (profile && profile.type === "token") { + selectedGithubToken = profile.token; + } } - return providers; + + let baseUrl = DEFAULT_COPILOT_API_BASE_URL; + if (selectedGithubToken) { + try { + const token = await resolveCopilotApiToken({ + githubToken: selectedGithubToken, + env, + }); + baseUrl = token.baseUrl; + } catch { + baseUrl = DEFAULT_COPILOT_API_BASE_URL; + } + } + + // pi-coding-agent's ModelRegistry marks a model "available" only if its + // `AuthStorage` has auth configured for that provider (via auth.json/env/etc). + // Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also + // write a runtime-only auth.json entry for pi-coding-agent to pick up. + // + // This is safe because it's (1) within Clawdbot's agent dir, (2) contains the + // GitHub token (not the exchanged Copilot token), and (3) matches existing + // patterns for OAuth-like providers in pi-coding-agent. + // Note: we deliberately do not write pi-coding-agent's `auth.json` here. + // Clawdbot uses its own auth store and exchanges tokens at runtime. + // `models list` uses Clawdbot's auth heuristics for availability. + + // We intentionally do NOT define custom models for Copilot in models.json. + // pi-coding-agent treats providers with models as replacements requiring apiKey. + // We only override baseUrl; the model list comes from pi-ai built-ins. + return { + baseUrl, + models: [], + } satisfies ProviderConfig; } export async function ensureClawdbotModelsJson( @@ -111,9 +151,21 @@ export async function ensureClawdbotModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir(); - const configuredProviders = cfg.models?.providers ?? {}; - const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); - const providers = { ...implicitProviders, ...configuredProviders }; + + const explicitProviders = (cfg.models?.providers ?? {}) as Record< + string, + ProviderConfig + >; + const implicitProviders = resolveImplicitProviders({ agentDir }); + const providers: Record = mergeProviders({ + implicit: implicitProviders, + explicit: explicitProviders, + }); + const implicitCopilot = await maybeBuildCopilotProvider({ agentDir }); + if (implicitCopilot && !providers["github-copilot"]) { + providers["github-copilot"] = implicitCopilot; + } + if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; } @@ -134,7 +186,10 @@ export async function ensureClawdbotModelsJson( } } - const normalizedProviders = normalizeProviders(mergedProviders); + const normalizedProviders = normalizeProviders({ + providers: mergedProviders, + agentDir, + }); const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; try { existingRaw = await fs.readFile(targetPath, "utf8"); diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 21e5e293f..21e5fa2da 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -103,7 +103,7 @@ describe("createClawdbotCodingTools", () => { }); }); - it("flattens simple anyOf/oneOf unions into single types", () => { + it("drops null-only union variants without flattening other unions", () => { const cleaned = __testing.cleanToolSchemaForGemini({ type: "object", properties: { @@ -125,8 +125,7 @@ describe("createClawdbotCodingTools", () => { | { type?: unknown; anyOf?: unknown; oneOf?: unknown } | undefined; expect(count?.anyOf).toBeUndefined(); - expect(count?.oneOf).toBeUndefined(); - expect(count?.type).toBe("string"); + expect(Array.isArray(count?.oneOf)).toBe(true); }); it("preserves action enums in normalized schemas", () => { diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index f371f3a91..2b12f4c23 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -67,56 +67,37 @@ function tryFlattenLiteralAnyOf( return null; } -const TYPE_UNION_IGNORED_KEYS = new Set([ - ...UNSUPPORTED_SCHEMA_KEYWORDS, - "description", - "title", - "default", -]); - -function tryFlattenTypeUnion(variants: unknown[]): { type: string } | null { - if (variants.length === 0) return null; - - const types = new Set(); - for (const variant of variants) { - if (!variant || typeof variant !== "object" || Array.isArray(variant)) { - return null; - } - const record = variant as Record; - const keys = Object.keys(record).filter( - (key) => !TYPE_UNION_IGNORED_KEYS.has(key), - ); - if (keys.length !== 1 || keys[0] !== "type") return null; - - const typeValue = record.type; - if (typeof typeValue === "string") { - types.add(typeValue); - continue; - } - if ( - Array.isArray(typeValue) && - typeValue.every((entry) => typeof entry === "string") - ) { - for (const entry of typeValue) types.add(entry); - continue; - } - return null; +function isNullSchema(variant: unknown): boolean { + if (!variant || typeof variant !== "object" || Array.isArray(variant)) { + return false; } + const record = variant as Record; + if ("const" in record && record.const === null) return true; + if (Array.isArray(record.enum) && record.enum.length === 1) { + return record.enum[0] === null; + } + const typeValue = record.type; + if (typeValue === "null") return true; + if ( + Array.isArray(typeValue) && + typeValue.length === 1 && + typeValue[0] === "null" + ) { + return true; + } + return false; +} - if (types.size === 0) return null; - - const pickType = () => { - if (types.has("string")) return "string"; - if (types.has("number")) return "number"; - if (types.has("integer")) return "number"; - if (types.has("boolean")) return "boolean"; - if (types.has("object")) return "object"; - if (types.has("array")) return "array"; - const nonNull = Array.from(types).find((value) => value !== "null"); - return nonNull ?? "string"; +function stripNullVariants(variants: unknown[]): { + variants: unknown[]; + stripped: boolean; +} { + if (variants.length === 0) return { variants, stripped: false }; + const nonNull = variants.filter((variant) => !isNullSchema(variant)); + return { + variants: nonNull, + stripped: nonNull.length !== variants.length, }; - - return { type: pickType() }; } type SchemaDefs = Map; @@ -218,19 +199,24 @@ function cleanSchemaForGeminiWithDefs( const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf); - const cleanedAnyOf = hasAnyOf + let cleanedAnyOf = hasAnyOf ? (obj.anyOf as unknown[]).map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), ) : undefined; - const cleanedOneOf = hasOneOf + let cleanedOneOf = hasOneOf ? (obj.oneOf as unknown[]).map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), ) : undefined; if (hasAnyOf) { - const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]); + const { variants: nonNullVariants, stripped } = stripNullVariants( + cleanedAnyOf ?? [], + ); + if (stripped) cleanedAnyOf = nonNullVariants; + + const flattened = tryFlattenLiteralAnyOf(nonNullVariants); if (flattened) { const result: Record = { type: flattened.type, @@ -241,19 +227,28 @@ function cleanSchemaForGeminiWithDefs( } return result; } - - const flattenedTypes = tryFlattenTypeUnion(cleanedAnyOf ?? []); - if (flattenedTypes) { - const result: Record = { ...flattenedTypes }; - for (const key of ["description", "title", "default"]) { - if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + if (stripped && nonNullVariants.length === 1) { + const lone = nonNullVariants[0]; + if (lone && typeof lone === "object" && !Array.isArray(lone)) { + const result: Record = { + ...(lone as Record), + }; + for (const key of ["description", "title", "default"]) { + if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + } + return result; } - return result; + return lone; } } if (hasOneOf) { - const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]); + const { variants: nonNullVariants, stripped } = stripNullVariants( + cleanedOneOf ?? [], + ); + if (stripped) cleanedOneOf = nonNullVariants; + + const flattened = tryFlattenLiteralAnyOf(nonNullVariants); if (flattened) { const result: Record = { type: flattened.type, @@ -264,14 +259,18 @@ function cleanSchemaForGeminiWithDefs( } return result; } - - const flattenedTypes = tryFlattenTypeUnion(cleanedOneOf ?? []); - if (flattenedTypes) { - const result: Record = { ...flattenedTypes }; - for (const key of ["description", "title", "default"]) { - if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + if (stripped && nonNullVariants.length === 1) { + const lone = nonNullVariants[0]; + if (lone && typeof lone === "object" && !Array.isArray(lone)) { + const result: Record = { + ...(lone as Record), + }; + for (const key of ["description", "title", "default"]) { + if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + } + return result; } - return result; + return lone; } } @@ -286,6 +285,15 @@ function cleanSchemaForGeminiWithDefs( } if (key === "type" && (hasAnyOf || hasOneOf)) continue; + if ( + key === "type" && + Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ) { + const types = value.filter((entry) => entry !== "null"); + cleaned.type = types.length === 1 ? types[0] : types; + continue; + } if (key === "properties" && value && typeof value === "object") { const props = value as Record; diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 2a33aef54..08a042e7d 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -9,7 +9,6 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, - resolveAuthProfileOrder, upsertAuthProfile, } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -21,6 +20,7 @@ import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { ClawdbotConfig } from "../config/config.js"; import { upsertSharedEnvVar } from "../infra/env-file.js"; +import { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { @@ -40,22 +40,17 @@ import { applyMinimaxApiConfig, applyMinimaxApiProviderConfig, applyMinimaxConfig, + applyMinimaxHostedConfig, + applyMinimaxHostedProviderConfig, applyMinimaxProviderConfig, - applyMoonshotConfig, - applyMoonshotProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, - applyOpenrouterConfig, - applyOpenrouterProviderConfig, applyZaiConfig, - MOONSHOT_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, + MINIMAX_HOSTED_MODEL_REF, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, - setMoonshotApiKey, setOpencodeZenApiKey, - setOpenrouterApiKey, setZaiApiKey, writeOAuthCredentials, ZAI_DEFAULT_MODEL_REF, @@ -68,55 +63,6 @@ import { } from "./openai-codex-model-default.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; -const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; - -function normalizeApiKeyInput(raw: string): string { - const trimmed = String(raw ?? "").trim(); - if (!trimmed) return ""; - - // Handle shell-style assignments: export KEY="value" or KEY=value - const assignmentMatch = trimmed.match( - /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/, - ); - const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; - - const unquoted = - valuePart.length >= 2 && - ((valuePart.startsWith('"') && valuePart.endsWith('"')) || - (valuePart.startsWith("'") && valuePart.endsWith("'")) || - (valuePart.startsWith("`") && valuePart.endsWith("`"))) - ? valuePart.slice(1, -1) - : valuePart; - - const withoutSemicolon = unquoted.endsWith(";") - ? unquoted.slice(0, -1) - : unquoted; - - return withoutSemicolon.trim(); -} - -const validateApiKeyInput = (value: unknown) => - normalizeApiKeyInput(String(value ?? "")).length > 0 ? undefined : "Required"; - -function formatApiKeyPreview( - raw: string, - opts: { head?: number; tail?: number } = {}, -): string { - const trimmed = raw.trim(); - if (!trimmed) return "…"; - const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; - const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; - if (trimmed.length <= head + tail) { - const shortHead = Math.min(2, trimmed.length); - const shortTail = Math.min(2, trimmed.length - shortHead); - if (shortTail <= 0) { - return `${trimmed.slice(0, shortHead)}…`; - } - return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; - } - return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; -} - export async function warnIfModelConfigLooksOff( config: ClawdbotConfig, prompter: WizardPrompter, @@ -388,7 +334,7 @@ export async function applyAuthChoice(params: { const envKey = resolveEnvApiKey("openai"); if (envKey) { const useExisting = await params.prompter.confirm({ - message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + message: `Use existing OPENAI_API_KEY (${envKey.source})?`, initialValue: true, }); if (useExisting) { @@ -409,9 +355,9 @@ export async function applyAuthChoice(params: { const key = await params.prompter.text({ message: "Enter OpenAI API key", - validate: validateApiKeyInput, + validate: (value) => (value?.trim() ? undefined : "Required"), }); - const trimmed = normalizeApiKeyInput(String(key)); + const trimmed = String(key).trim(); const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: trimmed, @@ -421,115 +367,6 @@ export async function applyAuthChoice(params: { `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, "OpenAI API key", ); - } else if (params.authChoice === "openrouter-api-key") { - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const profileOrder = resolveAuthProfileOrder({ - cfg: nextConfig, - store, - provider: "openrouter", - }); - const existingProfileId = profileOrder.find((profileId) => - Boolean(store.profiles[profileId]), - ); - const existingCred = existingProfileId - ? store.profiles[existingProfileId] - : undefined; - let profileId = "openrouter:default"; - let mode: "api_key" | "oauth" | "token" = "api_key"; - let hasCredential = false; - - if (existingProfileId && existingCred?.type) { - profileId = existingProfileId; - mode = - existingCred.type === "oauth" - ? "oauth" - : existingCred.type === "token" - ? "token" - : "api_key"; - hasCredential = true; - } - - if (!hasCredential) { - const envKey = resolveEnvApiKey("openrouter"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpenrouterApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - } - - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenRouter API key", - validate: validateApiKeyInput, - }); - await setOpenrouterApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - hasCredential = true; - } - - if (hasCredential) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider: "openrouter", - mode, - }); - } - if (params.setDefaultModel) { - nextConfig = applyOpenrouterConfig(nextConfig); - await params.prompter.note( - `Default model set to ${OPENROUTER_DEFAULT_MODEL_REF}`, - "Model configured", - ); - } else { - nextConfig = applyOpenrouterProviderConfig(nextConfig); - agentModelOverride = OPENROUTER_DEFAULT_MODEL_REF; - await noteAgentModel(OPENROUTER_DEFAULT_MODEL_REF); - } - } else if (params.authChoice === "moonshot-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("moonshot"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMoonshotApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Moonshot API key", - validate: validateApiKeyInput, - }); - await setMoonshotApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "moonshot:default", - provider: "moonshot", - mode: "api_key", - }); - if (params.setDefaultModel) { - nextConfig = applyMoonshotConfig(nextConfig); - } else { - nextConfig = applyMoonshotProviderConfig(nextConfig); - agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF; - await noteAgentModel(MOONSHOT_DEFAULT_MODEL_REF); - } } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( @@ -742,25 +579,11 @@ export async function applyAuthChoice(params: { ); } } else if (params.authChoice === "gemini-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("google"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setGeminiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Gemini API key", - validate: validateApiKeyInput, - }); - await setGeminiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); - } + const key = await params.prompter.text({ + message: "Enter Gemini API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setGeminiApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", provider: "google", @@ -780,25 +603,11 @@ export async function applyAuthChoice(params: { await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL); } } else if (params.authChoice === "zai-api-key") { - let hasCredential = false; - const envKey = resolveEnvApiKey("zai"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setZaiApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Z.AI API key", - validate: validateApiKeyInput, - }); - await setZaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); - } + const key = await params.prompter.text({ + message: "Enter Z.AI API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setZaiApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "zai:default", provider: "zai", @@ -833,76 +642,33 @@ export async function applyAuthChoice(params: { await noteAgentModel(ZAI_DEFAULT_MODEL_REF); } } else if (params.authChoice === "apiKey") { - let hasCredential = false; - const envKey = process.env.ANTHROPIC_API_KEY?.trim(); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setAnthropicApiKey(envKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter Anthropic API key", - validate: validateApiKeyInput, - }); - await setAnthropicApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + const key = await params.prompter.text({ + message: "Enter Anthropic API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setAnthropicApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", provider: "anthropic", mode: "api_key", }); - } else if ( - params.authChoice === "minimax-cloud" || - params.authChoice === "minimax-api" || - params.authChoice === "minimax-api-lightning" - ) { - const modelId = - params.authChoice === "minimax-api-lightning" - ? "MiniMax-M2.1-lightning" - : "MiniMax-M2.1"; - let hasCredential = false; - const envKey = resolveEnvApiKey("minimax"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing MINIMAX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setMinimaxApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter MiniMax API key", - validate: validateApiKeyInput, - }); - await setMinimaxApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + } else if (params.authChoice === "minimax-cloud") { + const key = await params.prompter.text({ + message: "Enter MiniMax API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setMinimaxApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "minimax:default", provider: "minimax", mode: "api_key", }); if (params.setDefaultModel) { - nextConfig = applyMinimaxApiConfig(nextConfig, modelId); + nextConfig = applyMinimaxHostedConfig(nextConfig); } else { - const modelRef = `minimax/${modelId}`; - nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId); - agentModelOverride = modelRef; - await noteAgentModel(modelRef); + nextConfig = applyMinimaxHostedProviderConfig(nextConfig); + agentModelOverride = MINIMAX_HOSTED_MODEL_REF; + await noteAgentModel(MINIMAX_HOSTED_MODEL_REF); } } else if (params.authChoice === "minimax") { if (params.setDefaultModel) { @@ -912,6 +678,79 @@ export async function applyAuthChoice(params: { agentModelOverride = "lmstudio/minimax-m2.1-gs32"; await noteAgentModel("lmstudio/minimax-m2.1-gs32"); } + } else if (params.authChoice === "minimax-api") { + const key = await params.prompter.text({ + message: "Enter MiniMax API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setMinimaxApiKey(String(key).trim(), params.agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "minimax:default", + provider: "minimax", + mode: "api_key", + }); + if (params.setDefaultModel) { + nextConfig = applyMinimaxApiConfig(nextConfig); + } else { + nextConfig = applyMinimaxApiProviderConfig(nextConfig); + agentModelOverride = "minimax/MiniMax-M2.1"; + await noteAgentModel("minimax/MiniMax-M2.1"); + } + } else if (params.authChoice === "github-copilot") { + await params.prompter.note( + [ + "This will open a GitHub device login to authorize Copilot.", + "Requires an active GitHub Copilot subscription.", + ].join("\n"), + "GitHub Copilot", + ); + + if (!process.stdin.isTTY) { + await params.prompter.note( + "GitHub Copilot login requires an interactive TTY.", + "GitHub Copilot", + ); + return { config: nextConfig, agentModelOverride }; + } + + try { + await githubCopilotLoginCommand({ yes: true }, params.runtime); + } catch (err) { + await params.prompter.note( + `GitHub Copilot login failed: ${String(err)}`, + "GitHub Copilot", + ); + return { config: nextConfig, agentModelOverride }; + } + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "github-copilot:github", + provider: "github-copilot", + mode: "token", + }); + + if (params.setDefaultModel) { + const model = "github-copilot/gpt-4o"; + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...(typeof nextConfig.agents?.defaults?.model === "object" + ? nextConfig.agents.defaults.model + : undefined), + primary: model, + }, + }, + }, + }; + await params.prompter.note( + `Default model set to ${model}`, + "Model configured", + ); + } } else if (params.authChoice === "opencode-zen") { await params.prompter.note( [ @@ -921,28 +760,11 @@ export async function applyAuthChoice(params: { ].join("\n"), "OpenCode Zen", ); - let hasCredential = false; - const envKey = resolveEnvApiKey("opencode"); - if (envKey) { - const useExisting = await params.prompter.confirm({ - message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await setOpencodeZenApiKey(envKey.apiKey, params.agentDir); - hasCredential = true; - } - } - if (!hasCredential) { - const key = await params.prompter.text({ - message: "Enter OpenCode Zen API key", - validate: validateApiKeyInput, - }); - await setOpencodeZenApiKey( - normalizeApiKeyInput(String(key)), - params.agentDir, - ); - } + const key = await params.prompter.text({ + message: "Enter OpenCode Zen API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setOpencodeZenApiKey(String(key).trim(), params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "opencode:default", provider: "opencode", @@ -979,24 +801,19 @@ export function resolvePreferredProviderForAuthChoice( return "openai-codex"; case "openai-api-key": return "openai"; - case "openrouter-api-key": - return "openrouter"; - case "moonshot-api-key": - return "moonshot"; case "gemini-api-key": return "google"; - case "zai-api-key": - return "zai"; case "antigravity": return "google-antigravity"; case "minimax-cloud": case "minimax-api": - case "minimax-api-lightning": return "minimax"; case "minimax": return "lmstudio"; case "opencode-zen": return "opencode"; + case "github-copilot": + return "github-copilot"; default: return undefined; }