diff --git a/apps/server/package.json b/apps/server/package.json index 9bed86456..3ee140e38 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "express": "5.2.1", "morgan": "1.10.1", "node-pty": "1.1.0-beta41", + "openai": "^6.17.0", "ws": "8.18.3" }, "devDependencies": { diff --git a/apps/server/src/providers/kimi-provider.ts b/apps/server/src/providers/kimi-provider.ts new file mode 100644 index 000000000..49e63cf32 --- /dev/null +++ b/apps/server/src/providers/kimi-provider.ts @@ -0,0 +1,442 @@ +/** + * Kimi Provider - Executes queries using Kimi models via OpenRouter + * + * Uses OpenAI-compatible API via OpenRouter to access Moonshot AI's Kimi models. + * Supports streaming responses and tool/function calling. + */ + +import OpenAI from 'openai'; +import { BaseProvider } from './base-provider.js'; +import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; +import { validateBareModelId, type Credentials } from '@automaker/types'; + +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, +} from './types.js'; + +const logger = createLogger('KimiProvider'); + +/** OpenRouter base URL for API requests */ +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +/** OpenRouter headers for attribution */ +const OPENROUTER_HEADERS = { + 'HTTP-Referer': 'https://automaker.app', + 'X-Title': 'Automaker', +}; + +/** Kimi model ID prefix for canonical format */ +export const KIMI_MODEL_PREFIX = 'kimi-'; + +/** Map of Kimi model aliases to OpenRouter model IDs */ +export const KIMI_MODEL_MAP: Record = { + 'kimi-k2.5': 'moonshotai/kimi-k2.5', + 'kimi-k1.5': 'moonshotai/kimi-k1.5', + 'kimi-k1.5-long': 'moonshotai/kimi-k1.5-long', +}; + +/** + * Get the OpenRouter model ID for a Kimi model + * @param model - Model string (with or without kimi- prefix) + * @returns OpenRouter model ID + */ +function getOpenRouterModelId(model: string): string { + // Check direct mapping first + if (model in KIMI_MODEL_MAP) { + return KIMI_MODEL_MAP[model]; + } + + // Try with kimi- prefix + const prefixedModel = model.startsWith(KIMI_MODEL_PREFIX) + ? model + : `${KIMI_MODEL_PREFIX}${model}`; + if (prefixedModel in KIMI_MODEL_MAP) { + return KIMI_MODEL_MAP[prefixedModel]; + } + + // If it looks like an OpenRouter model ID, use it directly + if (model.includes('/')) { + return model; + } + + // Default to k2.5 if unknown + logger.warn(`Unknown Kimi model "${model}", defaulting to kimi-k2.5`); + return KIMI_MODEL_MAP['kimi-k2.5']; +} + +/** + * Get API key from credentials or environment + */ +function getApiKey(credentials?: Credentials): string | undefined { + // First check credentials (UI settings) + if (credentials?.apiKeys?.openrouter) { + return credentials.apiKeys.openrouter; + } + + // Fall back to environment variable + return process.env.OPENROUTER_API_KEY; +} + +/** + * Convert OpenAI tool calls to Automaker ContentBlock format + */ +function convertToolCalls( + toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] +): ContentBlock[] { + return toolCalls + .filter((tc) => tc.type === 'function') + .map((tc) => { + // Type guard: only function tool calls have the function property + const funcCall = tc as OpenAI.Chat.Completions.ChatCompletionMessageToolCall & { + type: 'function'; + function: { name: string; arguments: string }; + }; + return { + type: 'tool_use' as const, + tool_use_id: funcCall.id, + name: funcCall.function.name, + input: JSON.parse(funcCall.function.arguments || '{}'), + }; + }); +} + +/** + * Convert allowed tools to OpenAI function format + */ +function convertToolsToFunctions( + allowedTools?: string[] +): OpenAI.Chat.Completions.ChatCompletionTool[] | undefined { + if (!allowedTools || allowedTools.length === 0) { + return undefined; + } + + // Basic tool definitions - in a real implementation, these would come from + // the tool registry with full schemas + return allowedTools.map((tool) => ({ + type: 'function' as const, + function: { + name: tool, + description: `Execute the ${tool} tool`, + parameters: { + type: 'object', + properties: {}, + additionalProperties: true, + }, + }, + })); +} + +export class KimiProvider extends BaseProvider { + getName(): string { + return 'kimi'; + } + + /** + * Execute a query using Kimi via OpenRouter + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Validate that model doesn't have a provider prefix + validateBareModelId(options.model, 'KimiProvider'); + + const { + prompt, + model, + systemPrompt, + maxTurns = 1, + allowedTools, + abortController, + conversationHistory, + credentials, + } = options; + + // Get API key + const apiKey = getApiKey(credentials); + if (!apiKey) { + yield { + type: 'error', + error: + 'OpenRouter API key not configured. Please add your API key in Settings > Providers.', + }; + return; + } + + // Create OpenAI client configured for OpenRouter + const client = new OpenAI({ + baseURL: OPENROUTER_BASE_URL, + apiKey, + defaultHeaders: OPENROUTER_HEADERS, + }); + + // Get OpenRouter model ID + const openRouterModel = getOpenRouterModelId(model); + + // Build messages array + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = []; + + // Add system prompt if provided + if (systemPrompt && typeof systemPrompt === 'string') { + messages.push({ role: 'system', content: systemPrompt }); + } + + // Add conversation history + if (conversationHistory && conversationHistory.length > 0) { + for (const msg of conversationHistory) { + if (typeof msg.content === 'string') { + messages.push({ + role: msg.role, + content: msg.content, + }); + } else if (Array.isArray(msg.content)) { + // Handle content blocks + const textContent = msg.content + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); + if (textContent) { + messages.push({ + role: msg.role, + content: textContent, + }); + } + } + } + } + + // Add current prompt + if (typeof prompt === 'string') { + messages.push({ role: 'user', content: prompt }); + } else if (Array.isArray(prompt)) { + // Handle multi-part prompt (text blocks) + const textContent = prompt + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); + messages.push({ role: 'user', content: textContent || '' }); + } + + // Convert tools to OpenAI format + const tools = convertToolsToFunctions(allowedTools); + + logger.debug('[KimiProvider] SDK Configuration:', { + model: openRouterModel, + baseUrl: OPENROUTER_BASE_URL, + hasApiKey: !!apiKey, + messageCount: messages.length, + hasTools: !!tools, + maxTurns, + }); + + try { + // Create streaming completion + const stream = await client.chat.completions.create( + { + model: openRouterModel, + messages, + stream: true, + ...(tools && { tools }), + }, + { + signal: abortController?.signal, + } + ); + + // Accumulate the response + let accumulatedContent = ''; + let accumulatedToolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []; + let currentToolCall: Partial | null = + null; + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta; + + if (!delta) continue; + + // Handle text content + if (delta.content) { + accumulatedContent += delta.content; + + // Yield incremental text updates + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: accumulatedContent }], + }, + }; + } + + // Handle tool calls + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + if (tc.index !== undefined) { + // New tool call or continuation + if (!currentToolCall || tc.id) { + // Start new tool call + if (currentToolCall && currentToolCall.id) { + accumulatedToolCalls.push( + currentToolCall as OpenAI.Chat.Completions.ChatCompletionMessageToolCall + ); + } + currentToolCall = { + id: tc.id || `call_${Date.now()}_${tc.index}`, + type: 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '', + }, + }; + } else if (currentToolCall.function) { + // Append to existing tool call + if (tc.function?.name) { + currentToolCall.function.name += tc.function.name; + } + if (tc.function?.arguments) { + currentToolCall.function.arguments += tc.function.arguments; + } + } + } + } + } + } + + // Finalize any pending tool call + if (currentToolCall && currentToolCall.id && currentToolCall.function) { + accumulatedToolCalls.push( + currentToolCall as OpenAI.Chat.Completions.ChatCompletionMessageToolCall + ); + } + + // Build final content blocks + const contentBlocks: ContentBlock[] = []; + + if (accumulatedContent) { + contentBlocks.push({ type: 'text', text: accumulatedContent }); + } + + if (accumulatedToolCalls.length > 0) { + contentBlocks.push(...convertToolCalls(accumulatedToolCalls)); + } + + // Yield final message + if (contentBlocks.length > 0) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: contentBlocks, + }, + }; + } + + // Yield result message + yield { + type: 'result', + subtype: 'success', + result: accumulatedContent, + }; + } catch (error) { + // Handle abort + if (abortController?.signal.aborted) { + yield { + type: 'error', + error: 'Request was cancelled', + }; + return; + } + + // Enhance error with user-friendly message + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + + logger.error('executeQuery() error during execution:', { + type: errorInfo.type, + message: errorInfo.message, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: (error as Error).stack, + }); + + // Build enhanced error message + const message = errorInfo.isRateLimit + ? `${userMessage}\n\nTip: OpenRouter rate limits vary by model and plan. Consider waiting a moment before retrying.` + : userMessage; + + yield { + type: 'error', + error: message, + }; + } + } + + /** + * Detect Kimi/OpenRouter installation (checks for API key) + */ + async detectInstallation(): Promise { + const apiKey = getApiKey(); + const hasApiKey = !!apiKey; + + return { + installed: true, // Always "installed" since it's an API + method: 'sdk', + hasApiKey, + authenticated: hasApiKey, + }; + } + + /** + * Get available Kimi models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + modelString: 'kimi-k2.5', + provider: 'kimi', + description: 'Latest Kimi model - fast and capable', + contextWindow: 131072, + maxOutputTokens: 8192, + supportsVision: false, + supportsTools: true, + tier: 'standard' as const, + default: true, + }, + { + id: 'kimi-k1.5', + name: 'Kimi K1.5', + modelString: 'kimi-k1.5', + provider: 'kimi', + description: 'Kimi K1.5 - balanced performance', + contextWindow: 131072, + maxOutputTokens: 8192, + supportsVision: false, + supportsTools: true, + tier: 'standard' as const, + }, + { + id: 'kimi-k1.5-long', + name: 'Kimi K1.5 Long', + modelString: 'kimi-k1.5-long', + provider: 'kimi', + description: 'Kimi K1.5 with extended context', + contextWindow: 262144, + maxOutputTokens: 8192, + supportsVision: false, + supportsTools: true, + tier: 'standard' as const, + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ['tools', 'text']; + return supportedFeatures.includes(feature); + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index c2a181202..1ad2f82dc 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -7,7 +7,13 @@ import { BaseProvider } from './base-provider.js'; import type { InstallationStatus, ModelDefinition } from './types.js'; -import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types'; +import { + isCursorModel, + isCodexModel, + isOpencodeModel, + isKimiModel, + type ModelProvider, +} from '@automaker/types'; import * as fs from 'fs'; import * as path from 'path'; @@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record = { codex: '.codex-disconnected', cursor: '.cursor-disconnected', opencode: '.opencode-disconnected', + kimi: '.kimi-disconnected', }; /** @@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js'; import { CursorProvider } from './cursor-provider.js'; import { CodexProvider } from './codex-provider.js'; import { OpencodeProvider } from './opencode-provider.js'; +import { KimiProvider } from './kimi-provider.js'; // Register Claude provider registerProvider('claude', { @@ -301,3 +309,11 @@ registerProvider('opencode', { canHandleModel: (model: string) => isOpencodeModel(model), priority: 3, // Between codex (5) and claude (0) }); + +// Register Kimi provider (via OpenRouter) +registerProvider('kimi', { + factory: () => new KimiProvider(), + aliases: ['moonshot', 'openrouter-kimi'], + canHandleModel: (model: string) => isKimiModel(model), + priority: 4, // Between opencode (3) and codex (5) +}); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 8c760c700..04e4e2fd3 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -973,12 +973,14 @@ export class SettingsService { anthropic?: string; google?: string; openai?: string; + openrouter?: string; }; await this.updateCredentials({ apiKeys: { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + openrouter: apiKeys.openrouter || '', }, }); migratedCredentials = true; diff --git a/apps/ui/src/components/views/settings-view/providers/kimi-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/kimi-settings-tab.tsx new file mode 100644 index 000000000..8bd4dbb9f --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/kimi-settings-tab.tsx @@ -0,0 +1,403 @@ +import { useState, useCallback } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ProviderToggle } from './provider-toggle'; +import { Spinner } from '@/components/ui/spinner'; +import { + Key, + CheckCircle2, + AlertCircle, + Eye, + EyeOff, + Zap, + ExternalLink, + Info, + Trash2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { getElectronAPI } from '@/lib/electron'; + +/** Available Kimi models via OpenRouter */ +const KIMI_MODELS = [ + { id: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Latest model - fast and capable' }, + { id: 'kimi-k1.5', name: 'Kimi K1.5', description: 'Balanced performance' }, + { id: 'kimi-k1.5-long', name: 'Kimi K1.5 Long', description: 'Extended context window' }, +] as const; + +type KimiModelId = (typeof KIMI_MODELS)[number]['id']; + +export function KimiSettingsTab() { + const { apiKeys, setApiKeys } = useAppStore(); + + // Local state for API key input + const [apiKeyValue, setApiKeyValue] = useState(apiKeys.openrouter || ''); + const [showApiKey, setShowApiKey] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // Model selection state + const [selectedModel, setSelectedModel] = useState('kimi-k2.5'); + + // Validate API key format (OpenRouter keys start with sk-or-) + const isValidKeyFormat = (key: string) => { + return key.startsWith('sk-or-') || key.length === 0; + }; + + // Test connection to OpenRouter + const handleTestConnection = useCallback(async () => { + if (!apiKeyValue) return; + + setIsTesting(true); + setTestResult(null); + + try { + const api = getElectronAPI(); + if (api?.setup?.testOpenRouterConnection) { + const result = await api.setup.testOpenRouterConnection(apiKeyValue); + setTestResult({ + success: result.success, + message: result.success + ? 'Successfully connected to OpenRouter!' + : result.error || 'Failed to connect to OpenRouter', + }); + } else { + // Fallback: test via direct API call + const response = await fetch('https://openrouter.ai/api/v1/models', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKeyValue}`, + 'HTTP-Referer': 'https://automaker.app', + 'X-Title': 'Automaker', + }, + }); + + if (response.ok) { + setTestResult({ + success: true, + message: 'Successfully connected to OpenRouter!', + }); + } else { + const error = await response.json().catch(() => ({})); + setTestResult({ + success: false, + message: error.error?.message || `Connection failed: ${response.status}`, + }); + } + } + } catch (error) { + setTestResult({ + success: false, + message: error instanceof Error ? error.message : 'Failed to test connection', + }); + } finally { + setIsTesting(false); + } + }, [apiKeyValue]); + + // Save API key + const handleSave = useCallback(async () => { + if (!apiKeyValue) return; + + if (!isValidKeyFormat(apiKeyValue)) { + toast.error('Invalid API key format. OpenRouter keys should start with "sk-or-"'); + return; + } + + setIsSaving(true); + try { + const api = getElectronAPI(); + if (api?.setup?.saveApiKey) { + const result = await api.setup.saveApiKey('openrouter', apiKeyValue); + if (result.success) { + setApiKeys({ ...apiKeys, openrouter: apiKeyValue }); + toast.success('OpenRouter API key saved'); + } else { + toast.error(result.error || 'Failed to save API key'); + } + } else { + // Fallback: save to store directly + setApiKeys({ ...apiKeys, openrouter: apiKeyValue }); + toast.success('OpenRouter API key saved'); + } + } catch (error) { + toast.error('Failed to save API key'); + } finally { + setIsSaving(false); + } + }, [apiKeyValue, apiKeys, setApiKeys]); + + // Delete API key + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + const api = getElectronAPI(); + if (api?.setup?.deleteApiKey) { + const result = await api.setup.deleteApiKey('openrouter'); + if (result.success) { + setApiKeys({ ...apiKeys, openrouter: '' }); + setApiKeyValue(''); + setTestResult(null); + toast.success('OpenRouter API key deleted'); + } else { + toast.error(result.error || 'Failed to delete API key'); + } + } else { + // Fallback: clear from store directly + setApiKeys({ ...apiKeys, openrouter: '' }); + setApiKeyValue(''); + setTestResult(null); + toast.success('OpenRouter API key deleted'); + } + } catch (error) { + toast.error('Failed to delete API key'); + } finally { + setIsDeleting(false); + } + }, [apiKeys, setApiKeys]); + + const hasStoredKey = !!apiKeys.openrouter; + + return ( +
+ {/* Provider Visibility Toggle */} + + + {/* Provider Info */} +
+ +
+ Kimi via OpenRouter +

+ Access Moonshot AI's Kimi models through OpenRouter. Supports tool use and long context + windows. +

+
+
+ + {/* API Key Section */} +
+
+
+
+ +
+

+ OpenRouter API Key +

+ {hasStoredKey && } +
+

+ Required for accessing Kimi models via OpenRouter. +

+
+ +
+ {/* API Key Input */} +
+ +
+
+ { + setApiKeyValue(e.target.value); + setTestResult(null); + }} + placeholder="sk-or-..." + className={cn( + 'pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground', + apiKeyValue && !isValidKeyFormat(apiKeyValue) && 'border-yellow-500/50' + )} + /> + +
+ +
+ + {/* Format warning */} + {apiKeyValue && !isValidKeyFormat(apiKeyValue) && ( +

+ ⚠️ OpenRouter API keys typically start with "sk-or-" +

+ )} + + {/* Description */} +

+ Get your API key at{' '} + + openrouter.ai/keys + + +

+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+ + {/* Action Buttons */} +
+ + + {hasStoredKey && ( + + )} +
+
+
+ + {/* Model Selection */} +
+
+

Model Selection

+

+ Choose the default Kimi model for agent tasks. +

+
+ +
+
+ + +
+ + {/* Model Info */} +
+

+ Tip: Kimi K2.5 is recommended for + most tasks. Use K1.5 Long for documents requiring extended context. +

+
+
+
+
+ ); +} + +export default KimiSettingsTab; diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx index 6df2a4c5d..8aebdf672 100644 --- a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx +++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx @@ -1,20 +1,26 @@ import React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { + AnthropicIcon, + CursorIcon, + OpenAIIcon, + OpenRouterIcon, +} from '@/components/ui/provider-icon'; import { Cpu } from 'lucide-react'; import { CursorSettingsTab } from './cursor-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab'; import { CodexSettingsTab } from './codex-settings-tab'; import { OpencodeSettingsTab } from './opencode-settings-tab'; +import { KimiSettingsTab } from './kimi-settings-tab'; interface ProviderTabsProps { - defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode'; + defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'kimi'; } export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { return ( - + Claude @@ -31,6 +37,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { OpenCode + + + Kimi + @@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + + + + ); } diff --git a/apps/ui/src/components/views/settings-view/shared/types.ts b/apps/ui/src/components/views/settings-view/shared/types.ts index b0bdb977d..98bf4adcc 100644 --- a/apps/ui/src/components/views/settings-view/shared/types.ts +++ b/apps/ui/src/components/views/settings-view/shared/types.ts @@ -36,4 +36,5 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + openrouter: string; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 63dd79601..a048896a6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -232,6 +232,7 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + openrouter: string; } // Keyboard Shortcut with optional modifiers @@ -1444,6 +1445,7 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + openrouter: '', }, chatSessions: [], currentChatSession: null, diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a8f2644db..7a185343c 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -247,6 +247,7 @@ export { isClaudeModel, isCodexModel, isOpencodeModel, + isKimiModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index af776cb22..78b3b9d11 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -16,6 +16,7 @@ export const PROVIDER_PREFIXES = { cursor: 'cursor-', codex: 'codex-', opencode: 'opencode-', + kimi: 'kimi-', } as const; /** @@ -144,6 +145,32 @@ export function isOpencodeModel(model: string | undefined | null): boolean { return false; } +/** + * Check if a model string represents a Kimi model (via OpenRouter) + * + * Kimi models are identified by: + * - 'kimi-' prefix (canonical format) + * - 'moonshotai/' prefix (OpenRouter format) + * + * @param model - Model string to check + * @returns true if the model is a Kimi model + */ +export function isKimiModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Canonical format: kimi- prefix + if (model.startsWith(PROVIDER_PREFIXES.kimi)) { + return true; + } + + // OpenRouter format: moonshotai/ prefix + if (model.startsWith('moonshotai/')) { + return true; + } + + return false; +} + /** * Get the provider for a model string * @@ -151,7 +178,11 @@ export function isOpencodeModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { - // Check OpenCode first since it uses provider-prefixed formats that could conflict + // Check Kimi first (specific OpenRouter provider) + if (isKimiModel(model)) { + return 'kimi'; + } + // Check OpenCode since it uses provider-prefixed formats that could conflict if (isOpencodeModel(model)) { return 'opencode'; } @@ -215,6 +246,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.opencode)) { return `${PROVIDER_PREFIXES.opencode}${model}`; } + } else if (provider === 'kimi') { + if (!model.startsWith(PROVIDER_PREFIXES.kimi)) { + return `${PROVIDER_PREFIXES.kimi}${model}`; + } } // Claude models don't use prefixes return model; @@ -238,6 +273,7 @@ export function getBareModelId(model: string): string { * - OpenCode models: always have opencode- prefix (static) or provider/model format (dynamic) * - Claude models: can use legacy aliases or claude- prefix * - Codex models: always have codex- prefix + * - Kimi models: always have kimi- prefix * * @param model - Model string to normalize * @returns Normalized model string @@ -250,11 +286,18 @@ export function normalizeModelString(model: string | undefined | null): string { model.startsWith(PROVIDER_PREFIXES.cursor) || model.startsWith(PROVIDER_PREFIXES.codex) || model.startsWith(PROVIDER_PREFIXES.opencode) || + model.startsWith(PROVIDER_PREFIXES.kimi) || model.startsWith('claude-') ) { return model; } + // OpenRouter Kimi format: moonshotai/model -> kimi-model + if (model.startsWith('moonshotai/')) { + const kimiModel = model.replace('moonshotai/', ''); + return `${PROVIDER_PREFIXES.kimi}${kimiModel}`; + } + // Check if it's a legacy Cursor model ID if (model in LEGACY_CURSOR_MODEL_MAP) { return LEGACY_CURSOR_MODEL_MAP[model as keyof typeof LEGACY_CURSOR_MODEL_MAP]; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 8a10a6f81..6e58477a5 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -99,7 +99,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; +export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'kimi'; // ============================================================================ // Claude-Compatible Providers - Configuration for Claude-compatible API endpoints @@ -1060,6 +1060,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** OpenRouter API key (for Kimi and other models via OpenRouter) */ + openrouter: string; }; } @@ -1330,6 +1332,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + openrouter: '', }, }; diff --git a/package-lock.json b/package-lock.json index c86ba4aa9..8d507c887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "automaker", - "version": "0.12.0rc", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "automaker", - "version": "0.12.0rc", + "version": "0.13.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -32,7 +32,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.12.0", + "version": "0.13.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -51,6 +51,7 @@ "express": "5.2.1", "morgan": "1.10.1", "node-pty": "1.1.0-beta41", + "openai": "^6.17.0", "ws": "8.18.3" }, "devDependencies": { @@ -83,7 +84,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.12.0", + "version": "0.13.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -1532,7 +1533,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -6218,7 +6219,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6228,7 +6228,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8439,7 +8439,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -13400,6 +13399,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.17.0.tgz", + "integrity": "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",