From 05ac67c5207190fcee84040948ec6e93549eef18 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 16:59:08 +0000 Subject: [PATCH] refactor: split models-config provider helpers --- src/agents/models-config.providers.ts | 197 ++++++++++++++++++++++++ src/agents/models-config.ts | 207 ++------------------------ 2 files changed, 208 insertions(+), 196 deletions(-) create mode 100644 src/agents/models-config.providers.ts diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts new file mode 100644 index 000000000..d965e964c --- /dev/null +++ b/src/agents/models-config.providers.ts @@ -0,0 +1,197 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "./auth-profiles.js"; +import { resolveEnvApiKey } from "./model-auth.js"; + +type ModelsConfig = NonNullable; +export type ProviderConfig = NonNullable[string]; + +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, +}; + +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 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; +} + +export 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 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; +} + +export 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 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; +} + +function buildMinimaxProvider(): 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 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, + }, + ], + }; +} + +export function resolveImplicitProviders(params: { + agentDir: string; +}): ModelsConfig["providers"] { + const providers: Record = {}; + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + + const minimaxKey = + resolveEnvApiKeyVarName("minimax") ?? + resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + if (minimaxKey) { + providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; + } + + const moonshotKey = + resolveEnvApiKeyVarName("moonshot") ?? + resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + if (moonshotKey) { + providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; + } + + return providers; +} + diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 1143d553f..1b9e11a81 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -11,141 +11,20 @@ import { ensureAuthProfileStore, listProfilesForProvider, } from "./auth-profiles.js"; -import { resolveEnvApiKey } from "./model-auth.js"; +import type { ProviderConfig } from "./models-config.providers.js"; +import { + normalizeProviders, + 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, -}; - -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"; - return id; -} - -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(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 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; -} - async function readJson(pathname: string): Promise { try { const raw = await fs.readFile(pathname, "utf8"); @@ -155,75 +34,14 @@ 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 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 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; -} - async function maybeBuildCopilotProvider(params: { agentDir: string; env?: NodeJS.ProcessEnv; }): Promise { const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir); + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0; const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; @@ -275,7 +93,6 @@ async function maybeBuildCopilotProvider(params: { models: [], } satisfies ProviderConfig; } - export async function ensureClawdbotModelsJson( config?: ClawdbotConfig, agentDirOverride?: string, @@ -284,18 +101,16 @@ export async function ensureClawdbotModelsJson( const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir(); - - const explicitProviders = cfg.models?.providers ?? {}; - const implicitProviders = resolveImplicitProviders({ cfg, agentDir }); + const configuredProviders = cfg.models?.providers ?? {}; + const implicitProviders = resolveImplicitProviders({ agentDir }); const providers: Record = { ...implicitProviders, - ...explicitProviders, + ...configuredProviders, }; const implicitCopilot = await maybeBuildCopilotProvider({ agentDir }); if (implicitCopilot && !providers["github-copilot"]) { providers["github-copilot"] = implicitCopilot; } - if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; }