diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 7982b40ec..66b5a6532 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -3,13 +3,17 @@ import path from "node:path"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; -import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "./auth-profiles.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; @@ -22,10 +26,48 @@ const MINIMAX_API_COST = { cacheWrite: 10, }; +const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; +const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview"; +const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; +const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; +const MOONSHOT_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function normalizeApiKeyConfig(value: string): string { + const trimmed = value.trim(); + const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); + return match ? match[1]! : trimmed; +} + +function resolveEnvApiKeyVarName(provider: string): string | undefined { + const resolved = resolveEnvApiKey(provider); + if (!resolved) return undefined; + const match = /^(?:env: |shell env: )([A-Z0-9_]+)$/.exec(resolved.source); + return match ? match[1] : undefined; +} + +function resolveApiKeyFromProfiles(params: { + provider: string; + store: ReturnType; +}): string | undefined { + const ids = listProfilesForProvider(params.store, params.provider); + for (const id of ids) { + const cred = params.store.profiles[id]; + if (!cred) continue; + if (cred.type === "api_key") return cred.key; + if (cred.type === "token") return cred.token; + } + return undefined; +} + 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"; @@ -43,17 +85,59 @@ function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig { return mutated ? { ...provider, models } : provider; } -function normalizeProviders( - providers: ModelsConfig["providers"], -): ModelsConfig["providers"] { +function normalizeProviders(params: { + providers: ModelsConfig["providers"]; + agentDir: string; +}): ModelsConfig["providers"] { + const { providers } = params; if (!providers) return providers; + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); 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; + const normalizedKey = key.trim(); + let normalizedProvider = provider; + + // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". + if ( + normalizedProvider.apiKey && + normalizeApiKeyConfig(normalizedProvider.apiKey) !== + normalizedProvider.apiKey + ) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + apiKey: normalizeApiKeyConfig(normalizedProvider.apiKey), + }; + } + + // If a provider defines models, pi's ModelRegistry requires apiKey to be set. + // Fill it from the environment or auth profiles when possible. + const hasModels = + Array.isArray(normalizedProvider.models) && + normalizedProvider.models.length > 0; + if (hasModels && !normalizedProvider.apiKey?.trim()) { + const fromEnv = resolveEnvApiKeyVarName(normalizedKey); + const fromProfiles = resolveApiKeyFromProfiles({ + provider: normalizedKey, + store: authStore, + }); + const apiKey = fromEnv ?? fromProfiles; + if (apiKey?.trim()) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey }; + } + } + + if (normalizedKey === "google") { + const googleNormalized = normalizeGoogleProvider(normalizedProvider); + if (googleNormalized !== normalizedProvider) mutated = true; + normalizedProvider = googleNormalized; + } + + next[key] = normalizedProvider; } return mutated ? next : providers; } @@ -85,18 +169,48 @@ function buildMinimaxApiProvider(): ProviderConfig { }; } +function buildMoonshotProvider(): ProviderConfig { + return { + baseUrl: MOONSHOT_BASE_URL, + api: "openai-completions", + models: [ + { + id: MOONSHOT_DEFAULT_MODEL_ID, + name: "Kimi K2 0905 Preview", + reasoning: false, + input: ["text"], + cost: MOONSHOT_DEFAULT_COST, + contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, + maxTokens: MOONSHOT_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(); + + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + + const minimaxKey = + resolveEnvApiKeyVarName("minimax") ?? + resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + if (minimaxKey) { + providers.minimax = { ...buildMinimaxApiProvider(), apiKey: minimaxKey }; } + + const moonshotKey = + resolveEnvApiKeyVarName("moonshot") ?? + resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + if (moonshotKey) { + providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; + } + return providers; } @@ -131,7 +245,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");