diff --git a/src/components/settings/ai-settings-form.tsx b/src/components/settings/ai-settings-form.tsx index 8ba5896..5e97c08 100644 --- a/src/components/settings/ai-settings-form.tsx +++ b/src/components/settings/ai-settings-form.tsx @@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' -import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react' +import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal, Info } from 'lucide-react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -67,7 +67,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { }, }) - // Fetch available models from OpenAI API + const watchProvider = form.watch('ai_provider') + const isLiteLLM = watchProvider === 'litellm' + + // Fetch available models from OpenAI API (skip for LiteLLM — no models.list support) const { data: modelsData, isLoading: modelsLoading, @@ -76,6 +79,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { } = trpc.settings.listAIModels.useQuery(undefined, { staleTime: 5 * 60 * 1000, // Cache for 5 minutes retry: false, + enabled: !isLiteLLM, }) const updateSettings = trpc.settings.updateMultiple.useMutation({ @@ -182,32 +186,50 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { - OpenAI + OpenAI (API Key) + LiteLLM Proxy (ChatGPT Subscription) - AI provider for smart assignment suggestions + {field.value === 'litellm' + ? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription' + : 'Direct OpenAI API access using your API key'} )} /> + {isLiteLLM && ( + + + + LiteLLM Proxy Mode — AI calls will be routed through your LiteLLM proxy + using your ChatGPT subscription. Token limits are automatically stripped (not supported by ChatGPT backend). + Make sure your LiteLLM proxy is running and accessible. + + + )} + ( - API Key + {isLiteLLM ? 'API Key (Optional)' : 'API Key'} - Your OpenAI API key. Leave blank to keep the existing key. + {isLiteLLM + ? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.' + : 'Your OpenAI API key. Leave blank to keep the existing key.'} @@ -219,16 +241,26 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { name="openai_base_url" render={({ field }) => ( - API Base URL (Optional) + {isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'} - Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI. - Use https://openrouter.ai/api/v1 for OpenRouter (access Claude, Gemini, Llama, etc.) + {isLiteLLM ? ( + <> + URL of your LiteLLM proxy. Typically{' '} + http://localhost:4000{' '} + or your server address. + > + ) : ( + <> + Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI. + Use https://openrouter.ai/api/v1 for OpenRouter. + > + )} @@ -242,7 +274,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { Model - {modelsData?.success && ( + {!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && ( - {modelsLoading ? ( + {isLiteLLM || modelsData?.manualEntry ? ( + field.onChange(e.target.value)} + placeholder="chatgpt/gpt-5.2" + /> + ) : modelsLoading ? ( ) : modelsError || !modelsData?.success ? ( @@ -303,7 +341,15 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { )} - {form.watch('ai_model')?.startsWith('o') ? ( + {isLiteLLM ? ( + <> + Enter the model ID with the{' '} + chatgpt/ prefix. + Examples:{' '} + chatgpt/gpt-5.2,{' '} + chatgpt/gpt-5.2-codex + > + ) : form.watch('ai_model')?.startsWith('o') ? ( Reasoning model - optimized for complex analysis tasks diff --git a/src/lib/openai.ts b/src/lib/openai.ts index 01fc525..d57ee0c 100644 --- a/src/lib/openai.ts +++ b/src/lib/openai.ts @@ -8,6 +8,33 @@ const globalForOpenAI = globalThis as unknown as { openaiInitialized: boolean } +// ─── Provider Detection ───────────────────────────────────────────────────── + +/** + * Get the configured AI provider from SystemSettings. + * Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy). + */ +export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> { + try { + const setting = await prisma.systemSettings.findUnique({ + where: { key: 'ai_provider' }, + }) + const value = setting?.value || 'openai' + return value === 'litellm' ? 'litellm' : 'openai' + } catch { + return 'openai' + } +} + +/** + * Check if a model ID indicates LiteLLM ChatGPT subscription routing. + * Models like 'chatgpt/gpt-5.2' use the chatgpt/ prefix. + * Used by buildCompletionParams (sync) to strip unsupported token limit fields. + */ +export function isLiteLLMChatGPTModel(model: string): boolean { + return model.toLowerCase().startsWith('chatgpt/') +} + // ─── Model Type Detection ──────────────────────────────────────────────────── /** @@ -168,6 +195,12 @@ export function buildCompletionParams( params.response_format = { type: 'json_object' } } + // LiteLLM ChatGPT subscription models reject token limit fields + if (isLiteLLMChatGPTModel(model)) { + delete params.max_tokens + delete params.max_completion_tokens + } + return params } @@ -209,8 +242,12 @@ async function getBaseURL(): Promise { */ async function createOpenAIClient(): Promise { const apiKey = await getOpenAIApiKey() + const provider = await getConfiguredProvider() - if (!apiKey) { + // LiteLLM proxy may not require a real API key + const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null) + + if (!effectiveApiKey) { console.warn('OpenAI API key not configured') return null } @@ -218,11 +255,11 @@ async function createOpenAIClient(): Promise { const baseURL = await getBaseURL() if (baseURL) { - console.log(`[OpenAI] Using custom base URL: ${baseURL}`) + console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`) } return new OpenAI({ - apiKey, + apiKey: effectiveApiKey, ...(baseURL ? { baseURL } : {}), }) } @@ -259,6 +296,12 @@ export function resetOpenAIClient(): void { * Check if OpenAI is configured and available */ export async function isOpenAIConfigured(): Promise { + const provider = await getConfiguredProvider() + if (provider === 'litellm') { + // LiteLLM just needs a base URL configured + const baseURL = await getBaseURL() + return !!baseURL + } const apiKey = await getOpenAIApiKey() return !!apiKey } @@ -270,8 +313,20 @@ export async function listAvailableModels(): Promise<{ success: boolean models?: string[] error?: string + manualEntry?: boolean }> { try { + const provider = await getConfiguredProvider() + + // LiteLLM proxy for ChatGPT subscription doesn't support models.list() + if (provider === 'litellm') { + return { + success: true, + models: [], + manualEntry: true, + } + } + const client = await getOpenAI() if (!client) { diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 11318cc..106a855 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -201,8 +201,8 @@ export const settingsRouter = router({ clearStorageProviderCache() } - // Reset OpenAI client if API key or base URL changed - if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) { + // Reset OpenAI client if API key, base URL, model, or provider changed + if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) { const { resetOpenAIClient } = await import('@/lib/openai') resetOpenAIClient() } @@ -247,6 +247,15 @@ export const settingsRouter = router({ listAIModels: superAdminProcedure.query(async () => { const result = await listAvailableModels() + // LiteLLM mode: manual model entry, no listing available + if (result.manualEntry) { + return { + success: true, + models: [], + manualEntry: true, + } + } + if (!result.success || !result.models) { return { success: false,
https://openrouter.ai/api/v1
http://localhost:4000
chatgpt/
chatgpt/gpt-5.2
chatgpt/gpt-5.2-codex