diff --git a/apps/kilocode-docs/docs/providers/zenmux.md b/apps/kilocode-docs/docs/providers/zenmux.md new file mode 100644 index 00000000000..8398795c8b9 --- /dev/null +++ b/apps/kilocode-docs/docs/providers/zenmux.md @@ -0,0 +1,197 @@ +--- +title: ZenMux +--- + +import Codicon from "@site/src/components/Codicon"; + +# Using ZenMux With Kilo Code + +[ZenMux](https://zenmux.ai) provides a unified API gateway to access multiple AI models from different providers through a single endpoint. It supports OpenAI, Anthropic, Google, and other major AI providers, automatically handling routing, fallbacks, and cost optimization. + +## Getting Started + +1. **Sign up for ZenMux:** Visit [zenmux.ai](https://zenmux.ai) to create an account. +2. **Get your API key:** After signing up, navigate to your dashboard to generate an API key. +3. **Configure in Kilo Code:** Add your API key to Kilo Code settings. + +## Configuration in Kilo Code + +1. **Open Kilo Code Settings:** Click the gear icon () in the Kilo Code panel. +2. **Select Provider:** Choose "ZenMux" from the "API Provider" dropdown. +3. **Enter API Key:** Paste your ZenMux API key into the "ZenMux API Key" field. +4. **Select Model:** Choose your desired model from the "Model" dropdown. +5. **(Optional) Custom Base URL:** If you need to use a custom base URL for the ZenMux API, check "Use custom base URL" and enter the URL. Leave this blank for most users. + +## Supported Models + +ZenMux supports a wide range of models from various providers: + +Visi [zenmux.ai/models](https://zenmux.ai/models) to see the complete list of available models. + +### Other Providers + +ZenMux also supports models from Meta, Mistral, and many other providers. Check your ZenMux dashboard for the complete list of available models. + +## API Compatibility + +ZenMux provides multiple API endpoints for different protocols: + +### OpenAI Compatible API + +Use the standard OpenAI SDK with ZenMux's base URL: + +```javascript +import OpenAI from "openai" + +const openai = new OpenAI({ + baseURL: "https://zenmux.ai/api/v1", + apiKey: "", +}) + +async function main() { + const completion = await openai.chat.completions.create({ + model: "openai/gpt-5", + messages: [ + { + role: "user", + content: "What is the meaning of life?", + }, + ], + }) + + console.log(completion.choices[0].message) +} + +main() +``` + +### Anthropic API + +For Anthropic models, use the dedicated endpoint: + +```typescript +import Anthropic from "@anthropic-ai/sdk" + +// 1. Initialize the Anthropic client +const anthropic = new Anthropic({ + // 2. Replace with the API key from your ZenMux console + apiKey: "", + // 3. Point the base URL to the ZenMux endpoint + baseURL: "https://zenmux.ai/api/anthropic", +}) + +async function main() { + const msg = await anthropic.messages.create({ + model: "anthropic/claude-sonnet-4.5", + max_tokens: 1024, + messages: [{ role: "user", content: "Hello, Claude" }], + }) + console.log(msg) +} + +main() +``` + +### Platform API + +The Get generation interface is used to query generation information, such as usage and costs. + +```bash +curl https://zenmux.ai/api/v1/generation?id= \ + -H "Authorization: Bearer $ZENMUX_API_KEY" +``` + +### Google Vertex AI API + +For Google models: + +```typescript +const genai = require("@google/genai") + +const client = new genai.GoogleGenAI({ + apiKey: "$ZENMUX_API_KEY", + vertexai: true, + httpOptions: { + baseUrl: "https://zenmux.ai/api/vertex-ai", + apiVersion: "v1", + }, +}) + +const response = await client.models.generateContent({ + model: "google/gemini-2.5-pro", + contents: "How does AI work?", +}) +console.log(response) +``` + +## Features + +### Automatic Routing + +ZenMux automatically routes your requests to the best available provider based on: + +- Model availability +- Response time +- Cost optimization +- Provider health status + +### Fallback Support + +If a provider is unavailable, ZenMux automatically falls back to alternative providers that support the same model capabilities. + +### Cost Optimization + +ZenMux can be configured to optimize for cost, routing requests to the most cost-effective provider while maintaining quality. + +### Zero Data Retention (ZDR) + +Enable ZDR mode to ensure that no request or response data is stored by ZenMux, providing maximum privacy for sensitive applications. + +## Advanced Configuration + +### Provider Routing + +You can specify routing preferences: + +- **Price**: Route to the lowest cost provider +- **Throughput**: Route to the provider with highest tokens/second +- **Latency**: Route to the provider with fastest response time + +### Data Collection Settings + +Control how ZenMux handles your data: + +- **Allow**: Allow data collection for service improvement +- **Deny**: Disable all data collection + +### Middle-Out Transform + +Enable the middle-out transform feature to optimize prompts that exceed model context limits. + +## Troubleshooting + +### API Key Issues + +- Ensure your API key is correctly copied without any extra spaces +- Check that your ZenMux account is active and has available credits +- Verify the API key has the necessary permissions + +### Model Availability + +- Some models may have regional restrictions +- Check the ZenMux dashboard for current model availability +- Ensure your account tier has access to the desired models + +### Connection Issues + +- Verify your internet connection +- Check if you're behind a firewall that might block API requests +- Try using a custom base URL if the default endpoint is blocked + +## Support + +For additional support: + +- Visit the [ZenMux documentation](https://zenmux.ai/docs) +- Contact ZenMux support through their dashboard +- Check the [Kilo Code GitHub repository](https://github.com/kilocode/kilocode) for integration-specific issues diff --git a/cli/src/config/mapper.ts b/cli/src/config/mapper.ts index 788d5032a4d..a4c665e40f5 100644 --- a/cli/src/config/mapper.ts +++ b/cli/src/config/mapper.ts @@ -102,6 +102,8 @@ export function getModelIdForProvider(provider: ProviderConfig): string { return provider.apiModelId || "" case "openrouter": return provider.openRouterModelId || "" + case "zenmux": + return provider.zenmuxModelId || "" case "ollama": return provider.ollamaModelId || "" case "lmstudio": diff --git a/cli/src/config/types.ts b/cli/src/config/types.ts index 93b9207acf4..8485770f9fe 100644 --- a/cli/src/config/types.ts +++ b/cli/src/config/types.ts @@ -166,6 +166,19 @@ type OpenRouterProviderConfig = BaseProviderConfig & { openRouterZdr?: boolean } +// kilocode_change start +type ZenMuxProviderConfig = BaseProviderConfig & { + provider: "zenmux" + zenmuxModelId?: string + zenmuxApiKey?: string + zenmuxBaseUrl?: string + zenmuxSpecificProvider?: string + zenmuxUseMiddleOutTransform?: boolean + zenmuxProviderDataCollection?: "allow" | "deny" + zenmuxProviderSort?: "price" | "throughput" | "latency" +} +// kilocode_change end + type OllamaProviderConfig = BaseProviderConfig & { provider: "ollama" ollamaModelId?: string @@ -447,6 +460,7 @@ export type ProviderConfig = | OpenAINativeProviderConfig | OpenAIProviderConfig | OpenRouterProviderConfig + | ZenMuxProviderConfig // kilocode_change | OllamaProviderConfig | LMStudioProviderConfig | GlamaProviderConfig diff --git a/cli/src/constants/providers/labels.ts b/cli/src/constants/providers/labels.ts index 27e0b73e245..0bea33a5ed4 100644 --- a/cli/src/constants/providers/labels.ts +++ b/cli/src/constants/providers/labels.ts @@ -9,6 +9,7 @@ export const PROVIDER_LABELS: Record = { anthropic: "Anthropic", "openai-native": "OpenAI", openrouter: "OpenRouter", + zenmux: "ZenMux", bedrock: "Amazon Bedrock", gemini: "Google Gemini", vertex: "GCP Vertex AI", diff --git a/cli/src/constants/providers/models.ts b/cli/src/constants/providers/models.ts index 20ba42460d1..aeb52bfeb70 100644 --- a/cli/src/constants/providers/models.ts +++ b/cli/src/constants/providers/models.ts @@ -48,6 +48,7 @@ import { minimaxModels, minimaxDefaultModelId, ovhCloudAiEndpointsDefaultModelId, + zenmuxDefaultModelId, } from "@roo-code/types" /** @@ -66,6 +67,7 @@ export type RouterName = | "deepinfra" | "vercel-ai-gateway" | "ovhcloud" + | "zenmux" /** * ModelInfo interface - mirrors the one from packages/types/src/model.ts @@ -121,6 +123,7 @@ export type RouterModels = Record export const PROVIDER_TO_ROUTER_NAME: Record = { kilocode: "kilocode", openrouter: "openrouter", + zenmux: "zenmux", // kilocode_change ollama: "ollama", lmstudio: "lmstudio", litellm: "litellm", @@ -173,6 +176,7 @@ export const PROVIDER_TO_ROUTER_NAME: Record = export const PROVIDER_MODEL_FIELD: Record = { kilocode: "kilocodeModel", openrouter: "openRouterModelId", + zenmux: "zenmuxModelId", // kilocode_change ollama: "ollamaModelId", lmstudio: "lmStudioModelId", litellm: "litellmModelId", @@ -283,6 +287,7 @@ export const DEFAULT_MODEL_IDS: Partial> = { roo: rooDefaultModelId, "gemini-cli": geminiCliDefaultModelId, ovhcloud: ovhCloudAiEndpointsDefaultModelId, + zenmux: zenmuxDefaultModelId, } /** @@ -461,6 +466,8 @@ export function getModelIdKey(provider: ProviderName): string { return "vercelAiGatewayModelId" case "ovhcloud": return "ovhCloudAiEndpointsModelId" + case "zenmux": + return "zenmuxModelId" default: return "apiModelId" } diff --git a/cli/src/constants/providers/settings.ts b/cli/src/constants/providers/settings.ts index f75af199fd1..bbd7b17e68c 100644 --- a/cli/src/constants/providers/settings.ts +++ b/cli/src/constants/providers/settings.ts @@ -91,6 +91,25 @@ export const FIELD_REGISTRY: Record = { placeholder: "Enter base URL (or leave empty for default)...", isOptional: true, }, + + // kilocode_change start - ZenMux fields + zenmuxApiKey: { + label: "API Key", + type: "password", + placeholder: "Enter ZenMux API key...", + }, + zenmuxModelId: { + label: "Model", + type: "text", + placeholder: "Enter model name...", + }, + zenmuxBaseUrl: { + label: "Base URL", + type: "text", + placeholder: "Enter base URL (or leave empty for default)...", + isOptional: true, + }, + // kilocode_change end openRouterProviderDataCollection: { label: "Provider Data Collection", type: "select", @@ -803,6 +822,13 @@ export const getProviderSettings = (provider: ProviderName, config: ProviderSett createFieldConfig("openRouterBaseUrl", config, "Default"), ] + case "zenmux": // kilocode_change + return [ + createFieldConfig("zenmuxApiKey", config), + createFieldConfig("zenmuxModelId", config, "openai/gpt-5"), + createFieldConfig("zenmuxBaseUrl", config, "Default"), + ] + case "openai-native": return [ createFieldConfig("openAiNativeApiKey", config), @@ -1055,6 +1081,7 @@ export const PROVIDER_DEFAULT_MODELS: Record = { anthropic: "claude-3-5-sonnet-20241022", "openai-native": "gpt-4o", openrouter: "anthropic/claude-3-5-sonnet", + zenmux: "openai/gpt-5", // kilocode_change bedrock: "anthropic.claude-3-5-sonnet-20241022-v2:0", gemini: "gemini-1.5-pro-latest", vertex: "claude-3-5-sonnet@20241022", diff --git a/cli/src/constants/providers/validation.ts b/cli/src/constants/providers/validation.ts index 27926585f08..123674efe69 100644 --- a/cli/src/constants/providers/validation.ts +++ b/cli/src/constants/providers/validation.ts @@ -9,6 +9,7 @@ export const PROVIDER_REQUIRED_FIELDS: Record = { anthropic: ["apiKey", "apiModelId"], "openai-native": ["openAiNativeApiKey", "apiModelId"], openrouter: ["openRouterApiKey", "openRouterModelId"], + zenmux: ["zenmuxApiKey", "zenmuxModelId"], // kilocode_change ollama: ["ollamaBaseUrl", "ollamaModelId"], lmstudio: ["lmStudioBaseUrl", "lmStudioModelId"], bedrock: ["awsAccessKey", "awsSecretKey", "awsRegion", "apiModelId"], diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index bfb7e17b009..4b3c8b6f0ca 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -55,6 +55,7 @@ export const dynamicProviders = [ "inception", "synthetic", "sap-ai-core", + "zenmux", // kilocode_change end "deepinfra", "io-intelligence", @@ -157,6 +158,7 @@ export const providerNames = [ "virtual-quota-fallback", "synthetic", "inception", + "zenmux", // kilocode_change end "sambanova", "vertex", @@ -247,6 +249,10 @@ const nanoGptSchema = baseProviderSettingsSchema.extend({ export const openRouterProviderDataCollectionSchema = z.enum(["allow", "deny"]) export const openRouterProviderSortSchema = z.enum(["price", "throughput", "latency"]) + +// ZenMux provider schemas - kilocode_change +export const zenmuxProviderDataCollectionSchema = z.enum(["allow", "deny"]) +export const zenmuxProviderSortSchema = z.enum(["price", "throughput", "latency"]) // kilocode_change end const openRouterSchema = baseProviderSettingsSchema.extend({ @@ -262,6 +268,19 @@ const openRouterSchema = baseProviderSettingsSchema.extend({ // kilocode_change end }) +// kilocode_change start +const zenmuxSchema = baseProviderSettingsSchema.extend({ + zenmuxApiKey: z.string().optional(), + zenmuxModelId: z.string().optional(), + zenmuxBaseUrl: z.string().optional(), + zenmuxSpecificProvider: z.string().optional(), + zenmuxUseMiddleOutTransform: z.boolean().optional(), + zenmuxProviderDataCollection: zenmuxProviderDataCollectionSchema.optional(), + zenmuxProviderSort: zenmuxProviderSortSchema.optional(), + zenmuxZdr: z.boolean().optional(), +}) +// kilocode_change end + const bedrockSchema = apiModelIdProviderModelSchema.extend({ awsAccessKey: z.string().optional(), awsSecretKey: z.string().optional(), @@ -546,6 +565,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv glamaSchema.merge(z.object({ apiProvider: z.literal("glama") })), // kilocode_change nanoGptSchema.merge(z.object({ apiProvider: z.literal("nano-gpt") })), // kilocode_change openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), + zenmuxSchema.merge(z.object({ apiProvider: z.literal("zenmux") })), // kilocode_change bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })), openAiSchema.merge(z.object({ apiProvider: z.literal("openai") })), @@ -598,6 +618,7 @@ export const providerSettingsSchema = z.object({ ...glamaSchema.shape, // kilocode_change ...nanoGptSchema.shape, // kilocode_change ...openRouterSchema.shape, + ...zenmuxSchema.shape, // kilocode_change ...bedrockSchema.shape, ...vertexSchema.shape, ...openAiSchema.shape, @@ -664,6 +685,7 @@ export const modelIdKeys = [ "glamaModelId", // kilocode_change "nanoGptModelId", // kilocode_change "openRouterModelId", + "zenmuxModelId", // kilocode_change "openAiModelId", "ollamaModelId", "lmStudioModelId", @@ -726,6 +748,7 @@ export const modelIdKeysByProvider: Record = { ovhcloud: "ovhCloudAiEndpointsModelId", inception: "inceptionLabsModelId", "sap-ai-core": "sapAiCoreModelId", + zenmux: "zenmuxModelId", // kilocode_change // kilocode_change end groq: "apiModelId", baseten: "apiModelId", @@ -886,6 +909,7 @@ export const MODELS_BY_PROVIDER: Record< inception: { id: "inception", label: "Inception", models: [] }, kilocode: { id: "kilocode", label: "Kilocode", models: [] }, "virtual-quota-fallback": { id: "virtual-quota-fallback", label: "Virtual Quota Fallback", models: [] }, + zenmux: { id: "zenmux", label: "ZenMux", models: [] }, // kilocode_change // kilocode_change end deepinfra: { id: "deepinfra", label: "DeepInfra", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 95550d70474..8fea0efa1bd 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -16,6 +16,7 @@ export * from "./synthetic.js" export * from "./inception.js" export * from "./minimax.js" export * from "./glama.js" +export * from "./zenmux.js" // kilocode_change end export * from "./groq.js" export * from "./huggingface.js" @@ -53,6 +54,7 @@ import { featherlessDefaultModelId } from "./featherless.js" import { fireworksDefaultModelId } from "./fireworks.js" import { geminiDefaultModelId } from "./gemini.js" import { glamaDefaultModelId } from "./glama.js" // kilocode_change +import { zenmuxDefaultModelId } from "./zenmux.js" // kilocode_change import { groqDefaultModelId } from "./groq.js" import { ioIntelligenceDefaultModelId } from "./io-intelligence.js" import { litellmDefaultModelId } from "./lite-llm.js" @@ -87,6 +89,8 @@ export function getProviderDefaultModelId( switch (provider) { case "openrouter": return openRouterDefaultModelId + case "zenmux": // kilocode_change + return zenmuxDefaultModelId // kilocode_change case "requesty": return requestyDefaultModelId // kilocode_change start diff --git a/packages/types/src/providers/zenmux.ts b/packages/types/src/providers/zenmux.ts new file mode 100644 index 00000000000..1fd878018ea --- /dev/null +++ b/packages/types/src/providers/zenmux.ts @@ -0,0 +1,17 @@ +// kilocode_change - new file +import type { ModelInfo } from "../model.js" + +// Default model for ZenMux - using OpenAI GPT-5 as default +export const zenmuxDefaultModelId = "anthropic/claude-opus-4" + +export const zenmuxDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 15.0, + outputPrice: 75.0, + cacheWritesPrice: 18.75, + cacheReadsPrice: 1.5, + description: "Claude Opus 4 via ZenMux", +} diff --git a/src/api/index.ts b/src/api/index.ts index 7bbac561cc1..f9a008258c5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -11,6 +11,7 @@ import { AwsBedrockHandler, CerebrasHandler, OpenRouterHandler, + ZenMuxHandler, // kilocode_change VertexHandler, AnthropicVertexHandler, OpenAiHandler, @@ -162,6 +163,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { // kilocode_change end case "openrouter": return new OpenRouterHandler(options) + case "zenmux": // kilocode_change + return new ZenMuxHandler(options) // kilocode_change case "bedrock": return new AwsBedrockHandler(options) case "vertex": diff --git a/src/api/providers/__tests__/zenmux.spec.ts b/src/api/providers/__tests__/zenmux.spec.ts new file mode 100644 index 00000000000..194d29dbee1 --- /dev/null +++ b/src/api/providers/__tests__/zenmux.spec.ts @@ -0,0 +1,47 @@ +// kilocode_change - new test file for ZenMux provider +import { ZenMuxHandler } from "../zenmux" +import { ApiHandlerOptions } from "../../../shared/api" + +describe("ZenMuxHandler", () => { + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + zenmuxApiKey: "test-api-key", + zenmuxModelId: "openai/gpt-4", + zenmuxBaseUrl: "https://test.zenmux.ai/api/v1", + } + }) + + test("should use default base URL when not provided", () => { + const optionsWithoutBaseUrl = { + ...mockOptions, + zenmuxBaseUrl: undefined, + } + const handler = new ZenMuxHandler(optionsWithoutBaseUrl) + // The handler should initialize without errors + expect(handler).toBeDefined() + }) + + test("should use provided base URL", () => { + const handler = new ZenMuxHandler(mockOptions) + expect(handler).toBeDefined() + // The base URL should be used in the OpenAI client + }) + + test("should handle missing API key gracefully", () => { + const optionsWithoutKey = { + ...mockOptions, + zenmuxApiKey: undefined, + } + const handler = new ZenMuxHandler(optionsWithoutKey) + expect(handler).toBeDefined() + }) + + test("should return correct model info", () => { + const handler = new ZenMuxHandler(mockOptions) + const model = handler.getModel() + expect(model.id).toBe("openai/gpt-4") + expect(model.info).toBeDefined() + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f4c2ab1ae47..b5527325fe0 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -40,6 +40,7 @@ import { getHuggingFaceModels } from "./huggingface" import { getRooModels } from "./roo" import { getChutesModels } from "./chutes" import { getNanoGptModels } from "./nano-gpt" //kilocode_change +import { getZenmuxModels } from "./zenmux" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -75,7 +76,6 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise + +/** + * ZenMuxModelsResponse + */ +const zenMuxModelsResponseSchema = z.object({ + data: z.array(zenMuxModelSchema), + object: z.string(), +}) + +/** + * getZenmuxRouterModels + */ +export async function getZenmuxModels( + options?: ApiHandlerOptions & { headers?: Record }, +): Promise> { + const models: Record = {} + const baseURL = "https://zenmux.ai/api/v1" + try { + const response = await fetch(`${baseURL}/models`, { + headers: { ...DEFAULT_HEADERS, ...(options?.headers ?? {}) }, + }) + const json = await response.json() + const result = zenMuxModelsResponseSchema.safeParse(json) + + if (!result.success) { + throw new Error("ZenMux models response is invalid: " + JSON.stringify(result.error.format(), undefined, 2)) + } + + const data = result.data.data + + for (const model of data) { + const { id, owned_by } = model + + const modelInfo: ModelInfo = { + maxTokens: 0, + contextWindow: 0, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: `${owned_by || "ZenMux"} model`, + displayName: id, + } + + models[id] = modelInfo + } + + console.log(`Successfully fetched ${Object.keys(models).length} ZenMux models`) + } catch (error) { + console.error(`Error fetching ZenMux models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + throw error + } + + return models +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index aaf6f03171b..6dae5542591 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -21,6 +21,7 @@ export { NanoGptHandler } from "./nano-gpt" // kilocode_change export { OpenAiNativeHandler } from "./openai-native" export { OpenAiHandler } from "./openai" export { OpenRouterHandler } from "./openrouter" +export { ZenMuxHandler } from "./zenmux" // kilocode_change export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" export { SambaNovaHandler } from "./sambanova" diff --git a/src/api/providers/zenmux.ts b/src/api/providers/zenmux.ts new file mode 100644 index 00000000000..9e2308dbed5 --- /dev/null +++ b/src/api/providers/zenmux.ts @@ -0,0 +1,496 @@ +// kilocode_change - new file +import OpenAI from "openai" +import type Anthropic from "@anthropic-ai/sdk" +import type { ModelInfo } from "@roo-code/types" +import { zenmuxDefaultModelId, zenmuxDefaultModelInfo } from "@roo-code/types" +import { ApiProviderError } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { ApiHandlerOptions, ModelRecord } from "../../shared/api" + +import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" +import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" +import type { OpenRouterReasoningParams } from "../transform/reasoning" +import { getModelParams } from "../transform/model-params" + +import { getModels } from "./fetchers/modelCache" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import { verifyFinishReason } from "./kilocode/verifyFinishReason" + +import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" +import { ChatCompletionTool } from "openai/resources" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToR1Format } from "../transform/r1-format" +import { resolveToolProtocol } from "../../utils/resolveToolProtocol" +import { TOOL_PROTOCOL } from "@roo-code/types" +import { ApiStreamChunk } from "../transform/stream" +import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" +import { KiloCodeChunkSchema } from "./kilocode/chunk-schema" + +// ZenMux provider parameters +type ZenMuxProviderParams = { + order?: string[] + only?: string[] + allow_fallbacks?: boolean + data_collection?: "allow" | "deny" + sort?: "price" | "throughput" | "latency" + zdr?: boolean +} + +// ZenMux-specific response types +type ZenMuxChatCompletionParams = Omit & { + model: string + provider?: ZenMuxProviderParams + reasoning?: OpenRouterReasoningParams +} + +// ZenMux error structure +interface ZenMuxErrorResponse { + message?: string + code?: number + metadata?: { raw?: string } +} + +// Usage interface for cost calculation +interface CompletionUsage { + completion_tokens?: number + completion_tokens_details?: { + reasoning_tokens?: number + } + prompt_tokens?: number + prompt_tokens_details?: { + cached_tokens?: number + } + total_tokens?: number + cost?: number + cost_details?: { + upstream_inference_cost?: number + } +} + +const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.3 + +export class ZenMuxHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + private client: OpenAI + protected models: ModelRecord = {} + protected endpoints: ModelRecord = {} + lastGenerationId?: string + + protected get providerName(): "ZenMux" { + return "ZenMux" as const + } + + private currentReasoningDetails: any[] = [] + + constructor(options: ApiHandlerOptions) { + super() + this.options = options + + const baseURL = this.options.zenmuxBaseUrl || "https://zenmux.ai/api/v1" + const apiKey = this.options.zenmuxApiKey ?? "not-provided" + + this.client = new OpenAI({ + baseURL: baseURL, + apiKey: apiKey, + defaultHeaders: DEFAULT_HEADERS, + }) + + // Load models asynchronously to populate cache before getModel() is called + this.loadDynamicModels().catch((error) => { + console.error("[ZenMuxHandler] Failed to load dynamic models:", error) + }) + } + + private async loadDynamicModels(): Promise { + try { + const models = await getModels({ provider: "zenmux" }) + this.models = models + } catch (error) { + console.error("[ZenMuxHandler] Error loading dynamic models:", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + } + } + async createZenMuxStream( + client: OpenAI, + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + model: { id: string; info: ModelInfo }, + _reasoningEffort?: string, + thinkingBudgetTokens?: number, + zenMuxProviderSorting?: string, + tools?: Array, + _geminiThinkingLevel?: string, + ) { + // Convert Anthropic messages to OpenAI format + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // Build reasoning config if thinking budget is set + let reasoning: { max_tokens: number } | undefined + if (thinkingBudgetTokens && thinkingBudgetTokens > 0) { + reasoning = { max_tokens: thinkingBudgetTokens } + } + + // @ts-ignore-next-line + const stream = await client.chat.completions.create({ + model: model.id, + messages: openAiMessages, + stream: true, + stream_options: { include_usage: true }, + ...(reasoning ? { reasoning } : {}), + ...(zenMuxProviderSorting && zenMuxProviderSorting !== "" + ? { + provider: { + routing: { + type: "priority", + primary_factor: zenMuxProviderSorting, + }, + }, + } + : {}), + ...this.getOpenAIToolParams(tools), + }) + + return stream + } + getOpenAIToolParams(tools?: ChatCompletionTool[], enableParallelToolCalls: boolean = false) { + return tools?.length + ? { + tools, + tool_choice: tools ? "auto" : undefined, + parallel_tool_calls: enableParallelToolCalls ? true : false, + } + : { + tools: undefined, + } + } + + getTotalCost(lastUsage: CompletionUsage): number { + return (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0) + } + + private handleStreamingError(error: ZenMuxErrorResponse, modelId: string, operation: string): never { + const rawErrorMessage = error?.metadata?.raw || error?.message + + const apiError = Object.assign( + new ApiProviderError( + rawErrorMessage ?? "Unknown error", + this.providerName, + modelId, + operation, + error?.code, + ), + { status: error?.code, error: { message: error?.message, metadata: error?.metadata } }, + ) + + TelemetryService.instance.captureException(apiError) + + throw new Error(`ZenMux API Error ${error?.code}: ${rawErrorMessage}`) + } + async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): AsyncGenerator { + this.lastGenerationId = undefined + const model = await this.fetchModel() + + let { id: modelId } = model + + // Reset reasoning_details accumulator for this request + this.currentReasoningDetails = [] + + // Convert Anthropic messages to OpenAI format. + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // DeepSeek highly recommends using user instead of system role. + if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + } + + // Process reasoning_details when switching models to Gemini for native tool call compatibility + const toolProtocol = resolveToolProtocol(this.options, model.info) + const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE + const isGemini = modelId.startsWith("google/gemini") + + // For Gemini with native protocol: inject fake reasoning.encrypted blocks for tool calls + // This is required when switching from other models to Gemini to satisfy API validation + if (isNativeProtocol && isGemini) { + openAiMessages = openAiMessages.map((msg) => { + if (msg.role === "assistant") { + const toolCalls = (msg as any).tool_calls as any[] | undefined + const existingDetails = (msg as any).reasoning_details as any[] | undefined + + // Only inject if there are tool calls and no existing encrypted reasoning + if (toolCalls && toolCalls.length > 0) { + const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false + + if (!hasEncrypted) { + const fakeEncrypted = toolCalls.map((tc, idx) => ({ + id: tc.id, + type: "reasoning.encrypted", + data: "skip_thought_signature_validator", + format: "google-gemini-v1", + index: (existingDetails?.length ?? 0) + idx, + })) + + return { + ...msg, + reasoning_details: [...(existingDetails ?? []), ...fakeEncrypted], + } + } + } + } + return msg + }) + } + + // Add cache breakpoints for supported models + if (modelId.startsWith("anthropic/claude") || modelId.startsWith("google/gemini")) { + if (modelId.startsWith("google")) { + addGeminiCacheBreakpoints(systemPrompt, openAiMessages) + } else { + addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) + } + } + + let stream + try { + stream = await this.createZenMuxStream( + this.client, + systemPrompt, + messages, + model, + this.options.reasoningEffort, + this.options.modelMaxThinkingTokens, + this.options.zenmuxProviderSort, + metadata?.tools, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) + throw error + } + + let lastUsage: CompletionUsage | undefined = undefined + let inferenceProvider: string | undefined + // Accumulator for reasoning_details: accumulate text by type-index key + const reasoningDetailsAccumulator = new Map< + string, + { + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index: number + } + >() + + for await (const chunk of stream) { + // Handle ZenMux streaming error response + if ("error" in chunk) { + this.handleStreamingError(chunk.error as ZenMuxErrorResponse, modelId, "createMessage") + } + + const kiloCodeChunk = KiloCodeChunkSchema.safeParse(chunk).data + inferenceProvider = + kiloCodeChunk?.choices?.[0]?.delta?.provider_metadata?.gateway?.routing?.resolvedProvider ?? + kiloCodeChunk?.provider ?? + inferenceProvider + + verifyFinishReason(chunk.choices[0]) + const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + + if (delta) { + // Handle reasoning_details array format + const deltaWithReasoning = delta as typeof delta & { + reasoning_details?: Array<{ + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index?: number + }> + } + + if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { + for (const detail of deltaWithReasoning.reasoning_details) { + const index = detail.index ?? 0 + const key = `${detail.type}-${index}` + const existing = reasoningDetailsAccumulator.get(key) + + if (existing) { + // Accumulate text/summary/data for existing reasoning detail + if (detail.text !== undefined) { + existing.text = (existing.text || "") + detail.text + } + if (detail.summary !== undefined) { + existing.summary = (existing.summary || "") + detail.summary + } + if (detail.data !== undefined) { + existing.data = (existing.data || "") + detail.data + } + // Update other fields if provided + if (detail.id !== undefined) existing.id = detail.id + if (detail.format !== undefined) existing.format = detail.format + if (detail.signature !== undefined) existing.signature = detail.signature + } else { + // Start new reasoning detail accumulation + reasoningDetailsAccumulator.set(key, { + type: detail.type, + text: detail.text, + summary: detail.summary, + data: detail.data, + id: detail.id, + format: detail.format, + signature: detail.signature, + index, + }) + } + + // Yield text for display (still fragmented for live streaming) + let reasoningText: string | undefined + if (detail.type === "reasoning.text" && typeof detail.text === "string") { + reasoningText = detail.text + } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { + reasoningText = detail.summary + } + + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } as ApiStreamChunk + } + } + } else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + // Handle legacy reasoning format + yield { type: "reasoning", text: delta.reasoning } as ApiStreamChunk + } + + if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") { + yield { type: "reasoning", text: delta.reasoning_content } as ApiStreamChunk + } + + // Check for tool calls in delta + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (delta.content) { + yield { type: "text", text: delta.content } + } + } + + // Process finish_reason to emit tool_call_end events + if (finishReason) { + const endEvents = NativeToolCallParser.processFinishReason(finishReason) + for (const event of endEvents) { + yield event + } + } + + if (chunk.usage) { + lastUsage = chunk.usage + } + } + + // After streaming completes, store the accumulated reasoning_details + if (reasoningDetailsAccumulator.size > 0) { + this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values()) + } + + if (lastUsage) { + yield { + type: "usage", + inputTokens: lastUsage.prompt_tokens || 0, + outputTokens: lastUsage.completion_tokens || 0, + cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, + reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, + totalCost: this.getTotalCost(lastUsage), + inferenceProvider, + } + } + } + + getReasoningDetails(): any[] | undefined { + return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined + } + public async fetchModel() { + const models = await getModels({ provider: "zenmux" }) + this.models = models + return this.getModel() + } + + override getModel() { + const id = this.options.zenmuxModelId ?? zenmuxDefaultModelId + let info = this.models[id] ?? zenmuxDefaultModelInfo + + const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" + + const params = getModelParams({ + format: "zenmux", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0, + }) + + return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } + } + + async completePrompt(prompt: string) { + let { id: modelId, maxTokens, temperature, reasoning, verbosity } = await this.fetchModel() + + const completionParams: ZenMuxChatCompletionParams = { + model: modelId, + max_tokens: maxTokens, + temperature, + messages: [{ role: "user", content: prompt }], + stream: false, + ...(reasoning && { reasoning }), + verbosity, + } + + let response + + try { + response = await this.client.chat.completions.create(completionParams) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) + throw error + } + + if ("error" in response) { + this.handleStreamingError(response.error as ZenMuxErrorResponse, modelId, "completePrompt") + } + + const completion = response as OpenAI.Chat.ChatCompletion + return completion.choices[0]?.message?.content || "" + } +} diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index d2e99db9b1e..8a34ec22bcc 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -25,8 +25,7 @@ import { getGeminiReasoning, getOpenRouterReasoning, } from "./reasoning" - -type Format = "anthropic" | "openai" | "gemini" | "openrouter" +type Format = "anthropic" | "openai" | "gemini" | "openrouter" | "zenmux" type GetModelParamsOptions = { format: T @@ -65,13 +64,26 @@ type OpenRouterModelParams = { reasoning: OpenRouterReasoningParams | undefined } & BaseModelParams -export type ModelParams = AnthropicModelParams | OpenAiModelParams | GeminiModelParams | OpenRouterModelParams +// kilocode_change start +type ZenMuxModelParams = { + format: "zenmux" + reasoning: OpenRouterReasoningParams | undefined +} & BaseModelParams +// kilocode_change end + +export type ModelParams = + | AnthropicModelParams + | OpenAiModelParams + | GeminiModelParams + | OpenRouterModelParams + | ZenMuxModelParams // kilocode_change // Function overloads for specific return types export function getModelParams(options: GetModelParamsOptions<"anthropic">): AnthropicModelParams export function getModelParams(options: GetModelParamsOptions<"openai">): OpenAiModelParams export function getModelParams(options: GetModelParamsOptions<"gemini">): GeminiModelParams export function getModelParams(options: GetModelParamsOptions<"openrouter">): OpenRouterModelParams +export function getModelParams(options: GetModelParamsOptions<"zenmux">): OpenRouterModelParams export function getModelParams({ format, modelId, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 99deac1e3cb..2a028f7bac8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -842,7 +842,6 @@ export const webviewMessageHandler = async ( // Optional single provider filter from webview const requestedProvider = message?.values?.provider const providerFilter = requestedProvider ? toRouterName(requestedProvider) : undefined - const routerModels: Record = providerFilter ? ({} as Record) : { @@ -868,8 +867,8 @@ export const webviewMessageHandler = async ( "sap-ai-core": {}, // kilocode_change chutes: {}, "nano-gpt": {}, // kilocode_change + zenmux: {}, } - const safeGetModels = async (options: GetModelsOptions): Promise => { try { return await getModels(options) @@ -970,6 +969,14 @@ export const webviewMessageHandler = async ( key: "chutes", options: { provider: "chutes", apiKey: apiConfiguration.chutesApiKey }, }, + { + key: "zenmux", + options: { + provider: "zenmux", + apiKey: apiConfiguration.zenmuxApiKey, + baseUrl: apiConfiguration.zenmuxBaseUrl ?? "https://zenmux.ai/api/v1", + }, + }, ] // kilocode_change end @@ -1012,7 +1019,6 @@ export const webviewMessageHandler = async ( results.forEach((result, index) => { const routerName = modelFetchPromises[index].key - if (result.status === "fulfilled") { routerModels[routerName] = result.value.models diff --git a/src/integrations/theme/getTheme.ts b/src/integrations/theme/getTheme.ts index 20171cc3045..706e54334f7 100644 --- a/src/integrations/theme/getTheme.ts +++ b/src/integrations/theme/getTheme.ts @@ -72,7 +72,6 @@ export async function getTheme() { const includeTheme = parseThemeString(includeThemeString) parsed = mergeJson(parsed, includeTheme) } - const converted = convertTheme(parsed) converted.base = ( diff --git a/src/shared/api.ts b/src/shared/api.ts index 2a956139238..3f4701b141d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -119,7 +119,7 @@ export const getModelMaxOutputTokens = ({ modelId: string model: ModelInfo settings?: ProviderSettings - format?: "anthropic" | "openai" | "gemini" | "openrouter" + format?: "anthropic" | "openai" | "gemini" | "openrouter" | "zenmux" }): number | undefined => { // Check for Claude Code specific max output tokens setting if (settings?.apiProvider === "claude-code") { @@ -133,7 +133,8 @@ export const getModelMaxOutputTokens = ({ const isAnthropicContext = modelId.includes("claude") || format === "anthropic" || - (format === "openrouter" && modelId.startsWith("anthropic/")) + (format === "openrouter" && modelId.startsWith("anthropic/")) || + (format === "zenmux" && modelId.startsWith("anthropic/")) // For "Hybrid" reasoning models, discard the model's actual maxTokens for Anthropic contexts /* kilocode_change: don't limit Anthropic model output, no idea why this was done before @@ -185,6 +186,7 @@ type CommonFetchParams = { const dynamicProviderExtras = { gemini: {} as { apiKey?: string; baseUrl?: string }, // kilocode_change openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type + zenmux: {} as { apiKey?: string; baseUrl?: string }, "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type huggingface: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type litellm: {} as { apiKey: string; baseUrl: string }, diff --git a/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts b/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts index d1fe30c8723..8021c8da6af 100644 --- a/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts +++ b/webview-ui/src/components/kilocode/hooks/__tests__/getModelsByProvider.spec.ts @@ -42,6 +42,7 @@ describe("getModelsByProvider", () => { synthetic: { "test-model": testModel }, inception: { "test-model": testModel }, roo: { "test-model": testModel }, + zenmux: { "test-model": testModel }, } it("returns models for all providers", () => { diff --git a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts index 9cf7a166bcd..8af4649dea0 100644 --- a/webview-ui/src/components/kilocode/hooks/useProviderModels.ts +++ b/webview-ui/src/components/kilocode/hooks/useProviderModels.ts @@ -58,6 +58,7 @@ import { internationalZAiDefaultModelId, mainlandZAiModels, mainlandZAiDefaultModelId, + zenmuxDefaultModelId, } from "@roo-code/types" import type { ModelRecord, RouterModels } from "@roo/api" import { useRouterModels } from "../../ui/hooks/useRouterModels" @@ -329,6 +330,12 @@ export const getModelsByProvider = ({ } } } + case "zenmux": { + return { + models: routerModels.zenmux, + defaultModel: zenmuxDefaultModelId, + } + } default: return { models: {}, diff --git a/webview-ui/src/components/kilocode/hooks/useSelectedModel.ts b/webview-ui/src/components/kilocode/hooks/useSelectedModel.ts index b297e753c22..58145a6721e 100644 --- a/webview-ui/src/components/kilocode/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/kilocode/hooks/useSelectedModel.ts @@ -19,6 +19,7 @@ export const getModelIdKey = ({ | "ovhCloudAiEndpointsModelId" // kilocode_change | "nanoGptModelId" // kilocode_change | "apiModelId" + | "zenmuxModelId" > => { switch (provider) { case "openrouter": { @@ -58,6 +59,9 @@ export const getModelIdKey = ({ case "nano-gpt": { return "nanoGptModelId" } + case "zenmux": { + return "zenmuxModelId" + } // kilocode_change end default: { return "apiModelId" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index d978449e695..3b79e540afc 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -9,6 +9,7 @@ import { type ProviderSettings, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, openRouterDefaultModelId, + zenmuxDefaultModelId, // kilocode_change requestyDefaultModelId, glamaDefaultModelId, // kilocode_change unboundDefaultModelId, @@ -99,6 +100,7 @@ import { OpenAI, OpenAICompatible, OpenRouter, + ZenMux, // kilocode_change QwenCode, Requesty, Roo, @@ -232,6 +234,8 @@ const ApiOptions = ({ googleGeminiBaseUrl: apiConfiguration?.googleGeminiBaseUrl, chutesApiKey: apiConfiguration?.chutesApiKey, syntheticApiKey: apiConfiguration?.syntheticApiKey, + zenmuxBaseUrl: apiConfiguration?.zenmuxBaseUrl, + zenmuxApiKey: apiConfiguration?.zenmuxApiKey, }) //const { data: openRouterModelProviders } = useOpenRouterModelProviders( @@ -383,6 +387,7 @@ const ApiOptions = ({ > = { deepinfra: { field: "deepInfraModelId", default: deepInfraDefaultModelId }, openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, + zenmux: { field: "zenmuxModelId", default: zenmuxDefaultModelId }, glama: { field: "glamaModelId", default: glamaDefaultModelId }, // kilocode_change unbound: { field: "unboundModelId", default: unboundDefaultModelId }, requesty: { field: "requestyModelId", default: requestyDefaultModelId }, @@ -587,6 +592,21 @@ const ApiOptions = ({ /> )} + {/* kilocode_change start */} + {selectedProvider === "zenmux" && ( + + )} + {/* kilocode_change end */} + {selectedProvider === "requesty" && ( interface ModelPickerProps { diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index a92f4f0e2c0..ac4c6a5d826 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -57,6 +57,7 @@ export const MODELS_BY_PROVIDER: Partial void + routerModels?: RouterModels + selectedModelId: string + uriScheme: string | undefined + simplifySettings?: boolean + organizationAllowList: OrganizationAllowList + modelValidationError?: string +} + +export const ZenMux = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + uriScheme: _uriScheme, + simplifySettings, + organizationAllowList, + modelValidationError, +}: ZenMuxProps) => { + const { t } = useAppTranslation() + + const [zenmuxBaseUrlSelected, setZenmuxBaseUrlSelected] = useState(!!apiConfiguration?.zenmuxBaseUrl) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + +
+ +
+
+
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.zenmuxApiKey && ( + + {t("settings:providers.getZenmuxApiKey")} + + )} + {!simplifySettings && ( + <> +
+ { + setZenmuxBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("zenmuxBaseUrl", "") + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {zenmuxBaseUrlSelected && ( + + )} +
+ + , + }} + /> + + + )} + + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 6af98e438fd..accb34dec33 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -18,6 +18,7 @@ export { Ollama } from "./Ollama" export { OpenAI } from "./OpenAI" export { OpenAICompatible } from "./OpenAICompatible" export { OpenRouter } from "./OpenRouter" +export { ZenMux } from "./ZenMux" // kilocode_change export { QwenCode } from "./QwenCode" export { Roo } from "./Roo" export { Requesty } from "./Requesty" diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts index bef96d7c5c0..d4d545cbcb9 100644 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ b/webview-ui/src/components/ui/hooks/useRouterModels.ts @@ -66,6 +66,8 @@ type RouterModelsQueryKey = { nanoGptApiKey?: string nanoGptModelList?: "all" | "personalized" | "subscription" syntheticApiKey?: string + zenmuxBaseUrl?: string + zenmuxApiKey?: string // Requesty, Unbound, etc should perhaps also be here, but they already have their own hacks for reloading } // kilocode_change end diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 99668eb74fc..750a94d24e0 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -98,6 +98,8 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { geminiApiKey: apiConfiguration?.geminiApiKey, googleGeminiBaseUrl: apiConfiguration?.googleGeminiBaseUrl, syntheticApiKey: apiConfiguration?.syntheticApiKey, + zenmuxBaseUrl: apiConfiguration?.zenmuxBaseUrl, + zenmuxApiKey: apiConfiguration?.zenmuxApiKey, }, // kilocode_change end { @@ -525,6 +527,11 @@ function getSelectedModel({ } return { id, info } } + case "zenmux": { + const id = getValidatedModelId(apiConfiguration.zenmuxModelId, routerModels.zenmux, defaultModelId) + const info = routerModels.zenmux?.[id] + return { id, info } + } // kilocode_change end // case "anthropic": // case "human-relay": diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts index d3ec33b91c8..30681868713 100644 --- a/webview-ui/src/utils/__tests__/validate.test.ts +++ b/webview-ui/src/utils/__tests__/validate.test.ts @@ -73,6 +73,7 @@ describe("Model Validation Functions", () => { // kilocode_change end roo: {}, chutes: {}, + zenmux: {}, } const allowAllOrganization: OrganizationAllowList = {