From 4dfcd56893be15c6b1357f848c9d13db30f4f301 Mon Sep 17 00:00:00 2001 From: Abhi <40645221+AbhisekBasu1@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:46:58 +0530 Subject: [PATCH 1/3] Fix pi-tools test ordering and clean-for-gemini handling - which fixes the 400 error people are experiencing trying to use antigravity on opus --- src/agents/pi-tools.test.ts | 26 +++++++ src/agents/schema/clean-for-gemini.ts | 98 +++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index e3797b51e..21e5e293f 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -103,6 +103,32 @@ describe("createClawdbotCodingTools", () => { }); }); + it("flattens simple anyOf/oneOf unions into single types", () => { + const cleaned = __testing.cleanToolSchemaForGemini({ + type: "object", + properties: { + parentId: { anyOf: [{ type: "string" }, { type: "null" }] }, + count: { oneOf: [{ type: "string" }, { type: "number" }] }, + }, + }) as { + properties?: Record; + }; + + const parentId = cleaned.properties?.parentId as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + expect(parentId?.anyOf).toBeUndefined(); + expect(parentId?.oneOf).toBeUndefined(); + expect(parentId?.type).toBe("string"); + + const count = cleaned.properties?.count as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + expect(count?.anyOf).toBeUndefined(); + expect(count?.oneOf).toBeUndefined(); + expect(count?.type).toBe("string"); + }); + it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); const toolNames = [ diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index 219319fb8..2d738bda6 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -67,6 +67,60 @@ 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; + } + + 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"; + }; + + return { type: pickType() }; +} + type SchemaDefs = Map; function extendSchemaDefs( @@ -166,6 +220,16 @@ function cleanSchemaForGeminiWithDefs( const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf); + const cleanedAnyOf = hasAnyOf + ? (obj.anyOf as unknown[]).map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ) + : undefined; + const cleanedOneOf = hasOneOf + ? (obj.oneOf as unknown[]).map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ) + : undefined; if (hasAnyOf) { const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]); @@ -179,6 +243,15 @@ 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]; + } + return result; + } } if (hasOneOf) { @@ -193,6 +266,15 @@ 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]; + } + return result; + } } const cleaned: Record = {}; @@ -218,13 +300,17 @@ function cleanSchemaForGeminiWithDefs( } else if (key === "items" && value && typeof value === "object") { cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack); } else if (key === "anyOf" && Array.isArray(value)) { - cleaned[key] = value.map((variant) => - cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), - ); + cleaned[key] = + cleanedAnyOf ?? + value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "oneOf" && Array.isArray(value)) { - cleaned[key] = value.map((variant) => - cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), - ); + cleaned[key] = + cleanedOneOf ?? + value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "allOf" && Array.isArray(value)) { cleaned[key] = value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), From ba1d80bd0048fb647b49067cc28ce7e196a37234 Mon Sep 17 00:00:00 2001 From: Abhi <40645221+AbhisekBasu1@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:58:02 +0530 Subject: [PATCH 2/3] formatting fix --- src/agents/models-config.ts | 203 +++++-------- src/agents/schema/clean-for-gemini.ts | 4 +- src/commands/auth-choice.ts | 407 +++++++++++++++++++------- 3 files changed, 370 insertions(+), 244 deletions(-) diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 28fc73723..b28ea7c51 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -2,78 +2,63 @@ 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 { - normalizeProviders, - type ProviderConfig, - resolveImplicitProviders, -} from "./models-config.providers.js"; +import { resolveEnvApiKey } from "./model-auth.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 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 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 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; +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; } - return out; + return mutated ? next : providers; } async function readJson(pathname: string): Promise { @@ -85,62 +70,37 @@ async function readJson(pathname: string): Promise { } } -async function maybeBuildCopilotProvider(params: { - agentDir: string; - env?: NodeJS.ProcessEnv; -}): Promise { - const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir); - 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; - } - } - - 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. +function buildMinimaxApiProvider(): ProviderConfig { return { - baseUrl, - models: [], - } satisfies ProviderConfig; + 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; + agentDir: string; +}): ModelsConfig["providers"] { + const providers: Record = {}; + const minimaxEnv = resolveEnvApiKey("minimax"); + const authStore = ensureAuthProfileStore(params.agentDir); + const hasMinimaxProfile = + listProfilesForProvider(authStore, "minimax").length > 0; + if (minimaxEnv || hasMinimaxProfile) { + providers.minimax = buildMinimaxApiProvider(); + } + return providers; } export async function ensureClawdbotModelsJson( @@ -151,21 +111,9 @@ export async function ensureClawdbotModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir(); - - 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; - } - + const configuredProviders = cfg.models?.providers ?? {}; + const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); + const providers = { ...implicitProviders, ...configuredProviders }; if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; } @@ -186,10 +134,7 @@ export async function ensureClawdbotModelsJson( } } - const normalizedProviders = normalizeProviders({ - providers: mergedProviders, - agentDir, - }); + const normalizedProviders = normalizeProviders(mergedProviders); const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; try { existingRaw = await fs.readFile(targetPath, "utf8"); diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index 2d738bda6..f371f3a91 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -74,9 +74,7 @@ const TYPE_UNION_IGNORED_KEYS = new Set([ "default", ]); -function tryFlattenTypeUnion( - variants: unknown[], -): { type: string } | null { +function tryFlattenTypeUnion(variants: unknown[]): { type: string } | null { if (variants.length === 0) return null; const types = new Set(); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 08a042e7d..2a33aef54 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -9,6 +9,7 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, + resolveAuthProfileOrder, upsertAuthProfile, } from "../agents/auth-profiles.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -20,7 +21,6 @@ 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,17 +40,22 @@ import { applyMinimaxApiConfig, applyMinimaxApiProviderConfig, applyMinimaxConfig, - applyMinimaxHostedConfig, - applyMinimaxHostedProviderConfig, applyMinimaxProviderConfig, + applyMoonshotConfig, + applyMoonshotProviderConfig, applyOpencodeZenConfig, applyOpencodeZenProviderConfig, + applyOpenrouterConfig, + applyOpenrouterProviderConfig, applyZaiConfig, - MINIMAX_HOSTED_MODEL_REF, + MOONSHOT_DEFAULT_MODEL_REF, + OPENROUTER_DEFAULT_MODEL_REF, setAnthropicApiKey, setGeminiApiKey, setMinimaxApiKey, + setMoonshotApiKey, setOpencodeZenApiKey, + setOpenrouterApiKey, setZaiApiKey, writeOAuthCredentials, ZAI_DEFAULT_MODEL_REF, @@ -63,6 +68,55 @@ 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, @@ -334,7 +388,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})?`, + message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, initialValue: true, }); if (useExisting) { @@ -355,9 +409,9 @@ export async function applyAuthChoice(params: { const key = await params.prompter.text({ message: "Enter OpenAI API key", - validate: (value) => (value?.trim() ? undefined : "Required"), + validate: validateApiKeyInput, }); - const trimmed = String(key).trim(); + const trimmed = normalizeApiKeyInput(String(key)); const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: trimmed, @@ -367,6 +421,115 @@ 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( @@ -579,11 +742,25 @@ export async function applyAuthChoice(params: { ); } } else if (params.authChoice === "gemini-api-key") { - const key = await params.prompter.text({ - message: "Enter Gemini API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - await setGeminiApiKey(String(key).trim(), params.agentDir); + 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); + } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", provider: "google", @@ -603,11 +780,25 @@ export async function applyAuthChoice(params: { await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL); } } else if (params.authChoice === "zai-api-key") { - 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); + 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); + } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "zai:default", provider: "zai", @@ -642,33 +833,76 @@ export async function applyAuthChoice(params: { await noteAgentModel(ZAI_DEFAULT_MODEL_REF); } } else if (params.authChoice === "apiKey") { - const key = await params.prompter.text({ - message: "Enter Anthropic API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - await setAnthropicApiKey(String(key).trim(), params.agentDir); + 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, + ); + } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", provider: "anthropic", mode: "api_key", }); - } 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); + } 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, + ); + } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "minimax:default", provider: "minimax", mode: "api_key", }); if (params.setDefaultModel) { - nextConfig = applyMinimaxHostedConfig(nextConfig); + nextConfig = applyMinimaxApiConfig(nextConfig, modelId); } else { - nextConfig = applyMinimaxHostedProviderConfig(nextConfig); - agentModelOverride = MINIMAX_HOSTED_MODEL_REF; - await noteAgentModel(MINIMAX_HOSTED_MODEL_REF); + const modelRef = `minimax/${modelId}`; + nextConfig = applyMinimaxApiProviderConfig(nextConfig, modelId); + agentModelOverride = modelRef; + await noteAgentModel(modelRef); } } else if (params.authChoice === "minimax") { if (params.setDefaultModel) { @@ -678,79 +912,6 @@ 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( [ @@ -760,11 +921,28 @@ export async function applyAuthChoice(params: { ].join("\n"), "OpenCode Zen", ); - 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); + 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, + ); + } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "opencode:default", provider: "opencode", @@ -801,19 +979,24 @@ 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; } From a27efd57bdca4eb488ee7af6574a8d11ab177bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 01:58:30 +0000 Subject: [PATCH 3/3] 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; }