diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 47000738717..5798db7b329 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -20,6 +20,7 @@ import { HunterIOIcon, IcypeasIcon, JinaAIIcon, + KimiIcon, LeadMagicIcon, LinkupIcon, MillionVerifierIcon, @@ -86,6 +87,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'xai-...', }, + { + id: 'kimi', + name: 'Kimi', + icon: KimiIcon, + description: 'LLM calls', + placeholder: 'sk-...', + }, { id: 'fireworks', name: 'Fireworks', @@ -298,6 +306,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'google', 'mistral', 'xai', + 'kimi', 'fireworks', 'together', 'baseten', diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 1ebe7b408bc..9ac49e147a7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3823,6 +3823,20 @@ export const ZaiIcon = (props: SVGProps) => ( ) +export const KimiIcon = (props: SVGProps) => ( + + Kimi + + + +) + export function MetaIcon(props: SVGProps) { const id = useId() const gradient1Id = `meta_gradient_1_${id}` diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 6e45fe2cad8..aa456962264 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -206,13 +206,20 @@ export async function getApiKeyWithBYOK( const isMistralModel = provider === 'mistral' const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' + const isKimiModel = provider === 'kimi' const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) if ( isHosted && workspaceId && - (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel) + (isOpenAIModel || + isClaudeModel || + isGeminiModel || + isMistralModel || + isZaiModel || + isXaiModel || + isKimiModel) ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index 0c2be6492bf..191eeb1605d 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -7,6 +7,7 @@ export const byokProviderIdSchema = z.enum([ 'google', 'mistral', 'zai', + 'kimi', 'xai', 'fireworks', 'together', diff --git a/apps/sim/lib/core/config/api-keys.ts b/apps/sim/lib/core/config/api-keys.ts index b435da46f22..c8812aa05e4 100644 --- a/apps/sim/lib/core/config/api-keys.ts +++ b/apps/sim/lib/core/config/api-keys.ts @@ -13,7 +13,8 @@ export function getRotatingApiKey(provider: string): string { provider !== 'gemini' && provider !== 'cohere' && provider !== 'zai' && - provider !== 'xai' + provider !== 'xai' && + provider !== 'kimi' ) { throw new Error(`No rotation implemented for provider: ${provider}`) } @@ -44,6 +45,10 @@ export function getRotatingApiKey(provider: string): string { if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1) if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2) if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3) + } else if (provider === 'kimi') { + if (env.KIMI_API_KEY_1) keys.push(env.KIMI_API_KEY_1) + if (env.KIMI_API_KEY_2) keys.push(env.KIMI_API_KEY_2) + if (env.KIMI_API_KEY_3) keys.push(env.KIMI_API_KEY_3) } if (keys.length === 0) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index d3c6f4ba61c..460973b1baa 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -154,6 +154,9 @@ export const env = createEnv({ ZAI_API_KEY_1: z.string().min(1).optional(), // Primary Z.ai API key for load balancing ZAI_API_KEY_2: z.string().min(1).optional(), // Additional Z.ai API key for load balancing ZAI_API_KEY_3: z.string().min(1).optional(), // Additional Z.ai API key for load balancing + KIMI_API_KEY_1: z.string().min(1).optional(), // Primary Kimi (Moonshot AI) API key for load balancing + KIMI_API_KEY_2: z.string().min(1).optional(), // Additional Kimi API key for load balancing + KIMI_API_KEY_3: z.string().min(1).optional(), // Additional Kimi API key for load balancing XAI_API_KEY_1: z.string().min(1).optional(), // Primary xAI API key for load balancing XAI_API_KEY_2: z.string().min(1).optional(), // Additional xAI API key for load balancing XAI_API_KEY_3: z.string().min(1).optional(), // Additional xAI API key for load balancing diff --git a/apps/sim/lib/tokenization/constants.ts b/apps/sim/lib/tokenization/constants.ts index 7012e0dfe23..2fb27ec88a5 100644 --- a/apps/sim/lib/tokenization/constants.ts +++ b/apps/sim/lib/tokenization/constants.ts @@ -76,6 +76,11 @@ export const TOKENIZATION_CONFIG = { confidence: 'medium', supportedMethods: ['heuristic', 'fallback'], }, + kimi: { + avgCharsPerToken: 4, + confidence: 'medium', + supportedMethods: ['heuristic', 'fallback'], + }, ollama: { avgCharsPerToken: 4, confidence: 'low', diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index c902d959382..71ead60cd0a 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -39,6 +39,7 @@ export type AttachmentProvider = | 'nvidia' | 'meta' | 'zai' + | 'kimi' export interface PreparedProviderAttachment { file: UserFile @@ -152,6 +153,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { nvidia: 'no file attachments in the current API adapter', meta: 'no file attachments in the current API adapter', zai: 'no file attachments in the current API adapter', + kimi: 'images through image_url message parts on multimodal models', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -175,6 +177,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'nvidia') return 'nvidia' if (providerId === 'meta') return 'meta' if (providerId === 'zai') return 'zai' + if (providerId === 'kimi') return 'kimi' return null } @@ -319,6 +322,7 @@ function isMimeTypeSupportedByProvider( case 'vllm': case 'litellm': case 'xai': + case 'kimi': return isImageMimeType(mimeType) case 'deepseek': case 'cerebras': diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts new file mode 100644 index 00000000000..0d02c00710a --- /dev/null +++ b/apps/sim/providers/kimi/index.ts @@ -0,0 +1,632 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { createReadableStreamFromKimiStream } from '@/providers/kimi/utils' +import { + getModelCapabilities, + getProviderDefaultModel, + getProviderModels, +} from '@/providers/models' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + enforceStrictSchema, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('KimiProvider') + +const KIMI_BASE_URL = 'https://api.moonshot.ai/v1' + +/** Kimi models whose thinking mode can be toggled off; the rest always reason. */ +const THINKING_TOGGLE_MODELS = new Set( + getProviderModels('kimi').filter((id) => + getModelCapabilities(id)?.thinking?.levels.includes('disabled') + ) +) + +function buildResponseFormatPayload( + responseFormat: NonNullable +) { + const isStrict = responseFormat.strict !== false + const rawSchema = responseFormat.schema || responseFormat + return { + type: 'json_schema' as const, + json_schema: { + name: responseFormat.name || 'response_schema', + schema: isStrict ? enforceStrictSchema(rawSchema) : rawSchema, + strict: isStrict, + }, + } +} + +/** + * Moonshot AI's Kimi models via an OpenAI-compatible chat-completions API (`api.moonshot.ai`), + * with these documented model-family constraints baked into the adapter: + * - Every current Kimi model pins `temperature`/`top_p` server-side (passing another value is + * rejected), so the adapter never sends `temperature` and no model declares the capability. + * - Output length is capped via `max_completion_tokens` (Kimi's documented parameter). + * - `thinking: { type }` maps from `request.thinkingLevel` on the models whose definition + * declares the toggle (currently kimi-k2.6); always-reasoning models (kimi-k3, + * kimi-k2.7-code) take no toggle, so the parameter is never sent for them. + * - `response_format: json_schema` structured output is supported natively (`name`/`strict`/ + * `schema` nesting per Kimi's API reference). + * - `tool_choice` supports `"auto"` and the `{ type: "function" }` object form, but the API + * rejects the object form whenever thinking is enabled ("tool_choice 'specified' is + * incompatible with thinking enabled", verified live). On models with a thinking toggle the + * adapter therefore sends `thinking: { type: "disabled" }` for the duration of a forced-tool + * request; on always-thinking models (kimi-k3, kimi-k2.7-code) it downgrades the forced + * choice to `"auto"` with a warning, mirroring the Z.ai adapter's behavior. + */ +export const kimiProvider: ProviderConfig = { + id: 'kimi', + name: 'Kimi', + description: "Moonshot AI's Kimi models via an OpenAI-compatible API", + version: '1.0.0', + models: getProviderModels('kimi'), + defaultModel: getProviderDefaultModel('kimi'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Kimi') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const kimi = new OpenAI({ + apiKey: request.apiKey, + baseURL: KIMI_BASE_URL, + }) + + const allMessages = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'kimi') + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: any = { + model: request.model, + messages: formattedMessages, + } + + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + if ( + THINKING_TOGGLE_MODELS.has(request.model) && + (request.thinkingLevel === 'enabled' || request.thinkingLevel === 'disabled') + ) { + payload.thinking = { type: request.thinkingLevel } + } + + if (request.responseFormat) { + payload.response_format = buildResponseFormatPayload(request.responseFormat) + } + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice + hasActiveTools = true + + if (typeof toolChoice === 'object') { + if (THINKING_TOGGLE_MODELS.has(request.model)) { + if (payload.thinking?.type === 'enabled') { + logger.warn( + 'Kimi rejects forced tool_choice while thinking is enabled — disabling thinking for this forced-tool request', + { model: request.model } + ) + } + payload.thinking = { type: 'disabled' } + } else { + logger.warn( + 'Kimi rejects forced tool_choice on always-thinking models — ignoring force setting and falling back to auto', + { forcedTools: preparedTools.forcedTools, model: request.model } + ) + payload.tool_choice = 'auto' + } + } + + logger.info('Kimi request configuration:', { + toolCount: filteredTools.length, + toolChoice: + typeof payload.tool_choice === 'string' + ? payload.tool_choice + : `force:${payload.tool_choice.function?.name}`, + model: request.model, + }) + } + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for Kimi request (no tools)') + + const streamResponse = await kimi.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await kimi.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + if ( + typeof originalToolChoice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'kimi' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + const assistantReasoning = ( + currentResponse.choices[0]?.message as { reasoning_content?: string } | undefined + )?.reasoning_content + + currentMessages.push({ + role: 'assistant', + content: null, + ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + currentResponse = await kimi.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + if ( + typeof nextPayload.tool_choice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + nextPayload.tool_choice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'kimi' } + ) + } + } catch (error) { + logger.error('Error in Kimi request:', { error }) + throw error + } + + if (request.stream) { + logger.info('Using streaming for final Kimi response after tool processing') + + const streamingPayload: any = { + ...payload, + messages: currentMessages, + stream: true, + stream_options: { include_usage: true }, + } + streamingPayload.tools = undefined + streamingPayload.tool_choice = undefined + + const streamResponse = await kimi.chat.completions.create( + streamingPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromKimiStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) + + return streamingResult + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in Kimi request:', { + error, + duration: totalDuration, + }) + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/kimi/utils.ts b/apps/sim/providers/kimi/utils.ts new file mode 100644 index 00000000000..c52a0cd50ad --- /dev/null +++ b/apps/sim/providers/kimi/utils.ts @@ -0,0 +1,10 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +export function createReadableStreamFromKimiStream( + kimiStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(kimiStream, 'Kimi', onComplete) +} diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 14dcee48590..630da93fb2e 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -219,6 +219,68 @@ describe('zai provider definition', () => { }) }) +describe('kimi provider definition', () => { + const kimi = PROVIDER_DEFINITIONS.kimi + + const expectedModels = [ + { id: 'kimi-k3', contextWindow: 1048576 }, + { id: 'kimi-k2.7-code', contextWindow: 262144 }, + { id: 'kimi-k2.7-code-highspeed', contextWindow: 262144 }, + { id: 'kimi-k2.6', contextWindow: 262144 }, + ] + + it('is registered with kimi-k2.6 as the default model', () => { + expect(kimi).toBeDefined() + expect(kimi.id).toBe('kimi') + // kimi-k2.6 (not the flagship kimi-k3) — k3 access is tier-gated on Moonshot accounts, + // and the default must be a model every account can serve. + expect(kimi.defaultModel).toBe('kimi-k2.6') + // No fallback pattern — an unscoped `/^kimi/` would overmatch Kimi weights re-hosted by + // other providers and misroute them to Moonshot's hosted billing. + expect(kimi.modelPatterns).toEqual([]) + }) + + it('exposes every Kimi model with the documented context window', () => { + expect(kimi.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id)) + for (const expected of expectedModels) { + const model = kimi.models.find((m) => m.id === expected.id) + expect(model?.contextWindow).toBe(expected.contextWindow) + } + }) + + it('declares no temperature capability since every current Kimi model pins it server-side', () => { + expect(kimi.capabilities?.temperature).toBeUndefined() + for (const model of kimi.models) { + expect(model.capabilities.temperature).toBeUndefined() + } + }) + + it('exposes the thinking toggle only on kimi-k2.6', () => { + for (const model of kimi.models) { + const hasToggle = model.id === 'kimi-k2.6' + if (hasToggle) { + expect(model.capabilities.thinking).toEqual({ + levels: ['disabled', 'enabled'], + default: 'enabled', + }) + } else { + expect(model.capabilities.thinking).toBeUndefined() + } + } + }) + + it('routes every kimi model ID to the kimi provider', () => { + const baseModels = getBaseModelProviders() + for (const expected of expectedModels) { + expect(baseModels[expected.id]).toBe('kimi') + } + }) + + it('is included in getHostedModels since Sim provides the Kimi key server-side', () => { + expect(getHostedModels()).toContain('kimi-k3') + }) +}) + describe('xai provider definition', () => { const xai = PROVIDER_DEFINITIONS.xai diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index bc5aa24cbe7..ae5e201c8e2 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -18,6 +18,7 @@ import { FireworksIcon, GeminiIcon, GroqIcon, + KimiIcon, LitellmIcon, MetaIcon, MistralIcon, @@ -2548,6 +2549,85 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + kimi: { + id: 'kimi', + name: 'Kimi', + description: "Moonshot AI's Kimi models via an OpenAI-compatible API", + defaultModel: 'kimi-k2.6', + // No fallback pattern — an unscoped `/^kimi/` would overmatch Kimi weights re-hosted by + // other providers (e.g. `moonshotai/kimi-*` on aggregators) and misroute them here. + modelPatterns: [], + icon: KimiIcon, + color: '#1783FF', + contextInformationAvailable: true, + capabilities: { + toolUsageControl: true, + }, + models: [ + { + id: 'kimi-k3', + pricing: { + input: 3.0, + cachedInput: 0.3, + output: 15.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + maxOutputTokens: 1048576, + }, + contextWindow: 1048576, + releaseDate: '2026-07-16', + recommended: true, + }, + { + id: 'kimi-k2.7-code', + pricing: { + input: 0.95, + cachedInput: 0.19, + output: 4.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + }, + contextWindow: 262144, + releaseDate: '2026-06-12', + }, + { + id: 'kimi-k2.7-code-highspeed', + pricing: { + input: 1.9, + cachedInput: 0.38, + output: 8.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + }, + contextWindow: 262144, + releaseDate: '2026-06-12', + speedOptimized: true, + }, + { + id: 'kimi-k2.6', + pricing: { + input: 0.95, + cachedInput: 0.16, + output: 4.0, + updatedAt: '2026-07-16', + }, + capabilities: { + toolUsageControl: true, + thinking: { + levels: ['disabled', 'enabled'], + default: 'enabled', + }, + }, + contextWindow: 262144, + }, + ], + }, zai: { id: 'zai', name: 'Z.ai', @@ -3922,6 +4002,7 @@ export function getHostedModels(): string[] { ...getProviderModels('google'), ...getProviderModels('zai'), ...getProviderModels('xai'), + ...getProviderModels('kimi'), ] } diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 9e98d02d134..529d7c0b09f 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -10,6 +10,7 @@ import { deepseekProvider } from '@/providers/deepseek' import { fireworksProvider } from '@/providers/fireworks' import { googleProvider } from '@/providers/google' import { groqProvider } from '@/providers/groq' +import { kimiProvider } from '@/providers/kimi' import { litellmProvider } from '@/providers/litellm' import { metaProvider } from '@/providers/meta' import { mistralProvider } from '@/providers/mistral' @@ -42,6 +43,7 @@ const providerRegistry: Record = { nvidia: nvidiaProvider, meta: metaProvider, zai: zaiProvider, + kimi: kimiProvider, vllm: vllmProvider, litellm: litellmProvider, mistral: mistralProvider, diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 289957a5296..c02dc2ebd08 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -16,6 +16,7 @@ export type ProviderId = | 'nvidia' | 'meta' | 'zai' + | 'kimi' | 'mistral' | 'ollama' | 'ollama-cloud' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1c6904c5c30..641423746e0 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -159,6 +159,7 @@ export const providers: Record = { nvidia: buildProviderMetadata('nvidia'), meta: buildProviderMetadata('meta'), zai: buildProviderMetadata('zai'), + kimi: buildProviderMetadata('kimi'), mistral: buildProviderMetadata('mistral'), bedrock: buildProviderMetadata('bedrock'), openrouter: buildProviderMetadata('openrouter'), @@ -905,8 +906,12 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str const isGeminiModel = provider === 'google' const isZaiModel = provider === 'zai' const isXaiModel = provider === 'xai' + const isKimiModel = provider === 'kimi' - if (isHosted && (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel)) { + if ( + isHosted && + (isOpenAIModel || isClaudeModel || isGeminiModel || isZaiModel || isXaiModel || isKimiModel) + ) { const hostedModels = getHostedModels() const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase()) diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 542e5e9a620..a004d61ebd9 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -8,6 +8,7 @@ export type BYOKProviderId = | 'google' | 'mistral' | 'zai' + | 'kimi' | 'xai' | 'fireworks' | 'together'