From 49fcd95e306e53f8a016c6d7c88271de332a943b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A3=E9=80=94?= Date: Wed, 28 Jan 2026 18:04:16 +0800 Subject: [PATCH] feat: add zenmux provider support Add zenmux.ai as a new AI provider with model discovery, authentication, and onboarding support. Co-Authored-By: Claude (anthropic/claude-opus-4.5) --- src/agents/model-auth.ts | 1 + src/agents/models-config.providers.ts | 120 ++++++++++++++++++ src/commands/auth-choice-options.ts | 10 +- .../auth-choice.apply.api-providers.ts | 84 ++++++++++++ .../auth-choice.preferred-provider.ts | 1 + src/commands/onboard-auth.config-core.ts | 42 ++++++ src/commands/onboard-auth.credentials.ts | 13 ++ src/commands/onboard-auth.ts | 4 + src/commands/onboard-types.ts | 2 + 9 files changed, 276 insertions(+), 1 deletion(-) diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..905a91fd4 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -277,6 +277,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { cerebras: "CEREBRAS_API_KEY", xai: "XAI_API_KEY", openrouter: "OPENROUTER_API_KEY", + zenmux: "ZENMUX_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", moonshot: "MOONSHOT_API_KEY", "kimi-code": "KIMICODE_API_KEY", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..e8b7b0e90 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -64,6 +64,17 @@ const QWEN_PORTAL_DEFAULT_COST = { cacheWrite: 0, }; +const ZENMUX_BASE_URL = "https://zenmux.ai/api/v1"; +const ZENMUX_MODELS_URL = "https://zenmux.ai/api/v1/models"; +const ZENMUX_DEFAULT_CONTEXT_WINDOW = 200000; +const ZENMUX_DEFAULT_MAX_TOKENS = 8192; +const ZENMUX_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + const OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"; const OLLAMA_API_BASE_URL = "http://127.0.0.1:11434"; const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; @@ -90,6 +101,40 @@ interface OllamaTagsResponse { models: OllamaModel[]; } +type ZenMuxPricingType = { + value: number; + unit: string; + currency: string; +}; +interface ZenmuxModel { + id: string; + display_name: string; + context_length: number; + input_modalities: string[]; + output_modalities: string[]; + capabilities: { + reasoning?: boolean; + }; + pricings: { + prompt?: ZenMuxPricingType[]; + completion?: ZenMuxPricingType[]; + input_cache_read?: ZenMuxPricingType[]; + input_cache_write_5_min?: ZenMuxPricingType[]; + input_cache_write_1_h?: ZenMuxPricingType[]; + input_cache_write?: ZenMuxPricingType[]; + web_search?: ZenMuxPricingType[]; + internal_reasoning?: ZenMuxPricingType[]; + video?: ZenMuxPricingType[]; + image?: ZenMuxPricingType[]; + audio?: ZenMuxPricingType[]; + audio_and_video?: ZenMuxPricingType[]; + }; +} + +interface ZenmuxModelsResponse { + data: ZenmuxModel[]; +} + async function discoverOllamaModels(): Promise { // Skip Ollama discovery in test environments if (process.env.VITEST || process.env.NODE_ENV === "test") { @@ -128,6 +173,65 @@ async function discoverOllamaModels(): Promise { } } +function extractZenmuxCost(pricings: ZenmuxModel["pricings"]): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} { + const getPrice = (arr?: ZenMuxPricingType[]): number => arr?.[0]?.value ?? 0; + + return { + input: getPrice(pricings.prompt), + output: getPrice(pricings.completion), + cacheRead: getPrice(pricings.input_cache_read), + cacheWrite: + getPrice(pricings.input_cache_write) || + getPrice(pricings.input_cache_write_5_min) || + getPrice(pricings.input_cache_write_1_h), + }; +} + +async function discoverZenmuxModels(): Promise { + // Skip ZenMux discovery in test environments + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return []; + } + try { + const response = await fetch(ZENMUX_MODELS_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + console.warn(`Failed to discover ZenMux models: ${response.status}`); + return []; + } + const data = (await response.json()) as ZenmuxModelsResponse; + if (!data.data || data.data.length === 0) { + console.warn("No ZenMux models found"); + return []; + } + return data.data.map((model) => { + const inputModalities = model.input_modalities ?? ["text"]; + const hasImage = inputModalities.includes("image"); + const input: Array<"text" | "image"> = hasImage ? ["text", "image"] : ["text"]; + const cost = model.pricings ? extractZenmuxCost(model.pricings) : ZENMUX_DEFAULT_COST; + return { + id: model.id, + name: model.display_name || model.id, + reasoning: model.capabilities?.reasoning ?? false, + input, + cost, + contextWindow: model.context_length ?? ZENMUX_DEFAULT_CONTEXT_WINDOW, + maxTokens: ZENMUX_DEFAULT_MAX_TOKENS, + }; + }); + } catch (error) { + console.warn(`Failed to discover ZenMux models: ${String(error)}`); + return []; + } +} + function normalizeApiKeyConfig(value: string): string { const trimmed = value.trim(); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); @@ -333,6 +437,15 @@ function buildQwenPortalProvider(): ProviderConfig { }; } +async function buildZenmuxProvider(): Promise { + const models = await discoverZenmuxModels(); + return { + baseUrl: ZENMUX_BASE_URL, + api: "openai-completions", + models, + }; +} + function buildSyntheticProvider(): ProviderConfig { return { baseUrl: SYNTHETIC_BASE_URL, @@ -410,6 +523,13 @@ export async function resolveImplicitProviders(params: { }; } + const zenmuxKey = + resolveEnvApiKeyVarName("zenmux") ?? + resolveApiKeyFromProfiles({ provider: "zenmux", store: authStore }); + if (zenmuxKey) { + providers.zenmux = { ...(await buildZenmuxProvider()), apiKey: zenmuxKey }; + } + // Ollama provider - only add if explicitly configured const ollamaKey = resolveEnvApiKeyVarName("ollama") ?? diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..766509b46 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -20,7 +20,8 @@ export type AuthChoiceGroupId = | "minimax" | "synthetic" | "venice" - | "qwen"; + | "qwen" + | "zenmux"; export type AuthChoiceGroup = { value: AuthChoiceGroupId; @@ -113,6 +114,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["opencode-zen"], }, + { + value: "zenmux", + label: "ZenMux", + hint: "API key", + choices: ["zenmux-api-key"], + }, ]; export function buildAuthChoiceOptions(params: { @@ -183,6 +190,7 @@ export function buildAuthChoiceOptions(params: { label: "MiniMax M2.1 Lightning", hint: "Faster, higher output cost", }); + options.push({ value: "zenmux-api-key", label: "ZenMux API key" }); if (params.includeSkip) { options.push({ value: "skip", label: "Skip for now" }); } diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8be02008b..2ecf363b8 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -28,12 +28,15 @@ import { applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, applyZaiConfig, + applyZenmuxConfig, + applyZenmuxProviderConfig, KIMI_CODE_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + ZENMUX_DEFAULT_MODEL_REF, setGeminiApiKey, setKimiCodeApiKey, setMoonshotApiKey, @@ -43,6 +46,7 @@ import { setVeniceApiKey, setVercelAiGatewayApiKey, setZaiApiKey, + setZenmuxApiKey, ZAI_DEFAULT_MODEL_REF, } from "./onboard-auth.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; @@ -85,6 +89,8 @@ export async function applyAuthChoiceApiProviders( authChoice = "venice-api-key"; } else if (params.opts.tokenProvider === "opencode") { authChoice = "opencode-zen"; + } else if (params.opts.tokenProvider === "zenmux") { + authChoice = "zenmux-api-key"; } } @@ -166,6 +172,84 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "zenmux-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "zenmux", + }); + const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); + const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + let profileId = "zenmux: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 && params.opts?.token && params.opts?.tokenProvider === "zenmux") { + await setZenmuxApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("zenmux"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing ZENMUX_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setZenmuxApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter ZenMux API key", + validate: validateApiKeyInput, + }); + await setZenmuxApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "zenmux", + mode, + }); + } + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: ZENMUX_DEFAULT_MODEL_REF, + applyDefaultConfig: applyZenmuxConfig, + applyProviderConfig: applyZenmuxProviderConfig, + noteDefault: ZENMUX_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "ai-gateway-api-key") { let hasCredential = false; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..cd4360ce7 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -28,6 +28,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { minimax: "lmstudio", "opencode-zen": "opencode", "qwen-portal": "qwen-portal", + "zenmux-api-key": "zenmux", }; export function resolvePreferredProviderForAuthChoice(choice: AuthChoice): string | undefined { diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 921ee01d1..c8ffdede0 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -15,6 +15,7 @@ import { OPENROUTER_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF, + ZENMUX_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; import { buildKimiCodeModelDefinition, @@ -137,6 +138,47 @@ export function applyOpenrouterConfig(cfg: MoltbotConfig): MoltbotConfig { }; } +export function applyZenmuxProviderConfig(cfg: MoltbotConfig): MoltbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[ZENMUX_DEFAULT_MODEL_REF] = { + ...models[ZENMUX_DEFAULT_MODEL_REF], + alias: models[ZENMUX_DEFAULT_MODEL_REF]?.alias ?? "ZenMux", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyZenmuxConfig(cfg: MoltbotConfig): MoltbotConfig { + const next = applyZenmuxProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: ZENMUX_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + export function applyMoonshotProviderConfig(cfg: MoltbotConfig): MoltbotConfig { const models = { ...cfg.agents?.defaults?.models }; models[MOONSHOT_DEFAULT_MODEL_REF] = { diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index b2fb58542..a2fcb77e7 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -114,6 +114,7 @@ export async function setVeniceApiKey(key: string, agentDir?: string) { export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7"; export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; +export const ZENMUX_DEFAULT_MODEL_REF = "zenmux/openai/gpt-5.2"; export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.5"; export async function setZaiApiKey(key: string, agentDir?: string) { @@ -141,6 +142,18 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) { }); } +export async function setZenmuxApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "zenmux:default", + credential: { + type: "api_key", + provider: "zenmux", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setVercelAiGatewayApiKey(key: string, agentDir?: string) { upsertAuthProfile({ profileId: "vercel-ai-gateway:default", diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index b122d89cf..a14dc27fe 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -18,6 +18,8 @@ export { applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, applyZaiConfig, + applyZenmuxConfig, + applyZenmuxProviderConfig, } from "./onboard-auth.config-core.js"; export { applyMinimaxApiConfig, @@ -45,9 +47,11 @@ export { setVeniceApiKey, setVercelAiGatewayApiKey, setZaiApiKey, + setZenmuxApiKey, writeOAuthCredentials, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, ZAI_DEFAULT_MODEL_REF, + ZENMUX_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { buildKimiCodeModelDefinition, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..07bc51360 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -31,6 +31,7 @@ export type AuthChoice = | "github-copilot" | "copilot-proxy" | "qwen-portal" + | "zenmux-api-key" | "skip"; export type GatewayAuthChoice = "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; @@ -71,6 +72,7 @@ export type OnboardOptions = { syntheticApiKey?: string; veniceApiKey?: string; opencodeZenApiKey?: string; + zenmuxApiKey?: string; gatewayPort?: number; gatewayBind?: GatewayBind; gatewayAuth?: GatewayAuthChoice;