Reduce AI costs: switch tagging to gpt-4o-mini, add custom base URL support
Build and Push Docker Image / build (push) Failing after 7s
Details
Build and Push Docker Image / build (push) Failing after 7s
Details
- Change AI tagging to use AI_MODELS.QUICK (gpt-4o-mini) instead of gpt-4o for 10-15x cost reduction on classification tasks - Add openai_base_url system setting for OpenAI-compatible providers (OpenRouter, Groq, Together AI, local models) - Reset OpenAI client singleton when API key, base URL, or model changes - Add base URL field to AI settings form with provider examples Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f12c29103c
commit
014bb15890
|
|
@ -36,6 +36,7 @@ const formSchema = z.object({
|
||||||
ai_model: z.string(),
|
ai_model: z.string(),
|
||||||
ai_send_descriptions: z.boolean(),
|
ai_send_descriptions: z.boolean(),
|
||||||
openai_api_key: z.string().optional(),
|
openai_api_key: z.string().optional(),
|
||||||
|
openai_base_url: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
@ -47,6 +48,7 @@ interface AISettingsFormProps {
|
||||||
ai_model?: string
|
ai_model?: string
|
||||||
ai_send_descriptions?: string
|
ai_send_descriptions?: string
|
||||||
openai_api_key?: string
|
openai_api_key?: string
|
||||||
|
openai_base_url?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +63,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
ai_model: settings.ai_model || 'gpt-4o',
|
ai_model: settings.ai_model || 'gpt-4o',
|
||||||
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
||||||
openai_api_key: '',
|
openai_api_key: '',
|
||||||
|
openai_base_url: settings.openai_base_url || '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -113,6 +116,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save base URL (empty string clears it)
|
||||||
|
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
|
||||||
|
|
||||||
updateSettings.mutate({ settings: settingsToUpdate })
|
updateSettings.mutate({ settings: settingsToUpdate })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,6 +214,27 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="openai_base_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>API Base URL (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
|
||||||
|
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter (access Claude, Gemini, Llama, etc.)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="ai_model"
|
name="ai_model"
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
'ai_model',
|
'ai_model',
|
||||||
'ai_send_descriptions',
|
'ai_send_descriptions',
|
||||||
'openai_api_key',
|
'openai_api_key',
|
||||||
|
'openai_base_url',
|
||||||
])
|
])
|
||||||
|
|
||||||
const brandingSettings = getSettingsByKeys([
|
const brandingSettings = getSettingsByKeys([
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,25 @@ async function getOpenAIApiKey(): Promise<string | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create OpenAI client instance
|
* Get custom base URL for OpenAI-compatible providers.
|
||||||
|
* Supports OpenRouter, Together AI, Groq, local models, etc.
|
||||||
|
* Set via Settings → AI or OPENAI_BASE_URL env var.
|
||||||
|
*/
|
||||||
|
async function getBaseURL(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const setting = await prisma.systemSettings.findUnique({
|
||||||
|
where: { key: 'openai_base_url' },
|
||||||
|
})
|
||||||
|
return setting?.value || process.env.OPENAI_BASE_URL || undefined
|
||||||
|
} catch {
|
||||||
|
return process.env.OPENAI_BASE_URL || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create OpenAI client instance.
|
||||||
|
* Supports custom baseURL for OpenAI-compatible providers
|
||||||
|
* (OpenRouter, Groq, Together AI, local models, etc.)
|
||||||
*/
|
*/
|
||||||
async function createOpenAIClient(): Promise<OpenAI | null> {
|
async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||||
const apiKey = await getOpenAIApiKey()
|
const apiKey = await getOpenAIApiKey()
|
||||||
|
|
@ -197,8 +215,15 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseURL = await getBaseURL()
|
||||||
|
|
||||||
|
if (baseURL) {
|
||||||
|
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
|
||||||
|
}
|
||||||
|
|
||||||
return new OpenAI({
|
return new OpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
|
...(baseURL ? { baseURL } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,6 +246,15 @@ export async function getOpenAI(): Promise<OpenAI | null> {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the OpenAI client singleton (e.g., after settings change).
|
||||||
|
* Next call to getOpenAI() will create a fresh client.
|
||||||
|
*/
|
||||||
|
export function resetOpenAIClient(): void {
|
||||||
|
globalForOpenAI.openai = undefined
|
||||||
|
globalForOpenAI.openaiInitialized = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if OpenAI is configured and available
|
* Check if OpenAI is configured and available
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,12 @@ export const settingsRouter = router({
|
||||||
clearStorageProviderCache()
|
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')) {
|
||||||
|
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||||
|
resetOpenAIClient()
|
||||||
|
}
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai'
|
||||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||||
import {
|
import {
|
||||||
|
|
@ -178,7 +178,8 @@ async function getAISuggestions(
|
||||||
return { suggestions: [], tokensUsed: 0 }
|
return { suggestions: [], tokensUsed: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = await getConfiguredModel()
|
// Use QUICK model — tag classification is simple, doesn't need expensive reasoning
|
||||||
|
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||||
|
|
||||||
// Build compact tag list for prompt
|
// Build compact tag list for prompt
|
||||||
const tagList = availableTags.map((t) => ({
|
const tagList = availableTags.map((t) => ({
|
||||||
|
|
@ -294,7 +295,8 @@ async function getAISuggestionsBatch(
|
||||||
return { suggestionsMap: new Map(), tokensUsed: 0 }
|
return { suggestionsMap: new Map(), tokensUsed: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = await getConfiguredModel()
|
// Use QUICK model — tag classification is simple, doesn't need expensive reasoning
|
||||||
|
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||||
const suggestionsMap = new Map<string, TagSuggestion[]>()
|
const suggestionsMap = new Map<string, TagSuggestion[]>()
|
||||||
|
|
||||||
// Build compact tag list (sent once for entire batch)
|
// Build compact tag list (sent once for entire batch)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue