Add Anthropic API integration, remove locale settings UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m15s

Anthropic API:
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option
- Add provider field to AIUsageLog for cross-provider cost tracking

Locale Settings Removal:
- Strip Localization tab from admin settings (mobile + desktop)
- Remove i18n settings from router and feature flags
- Remove LOCALIZATION from SettingCategory enum
- Keep franc document language detection intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 17:26:59 +01:00
parent 161cd1684a
commit f42b452899
9 changed files with 453 additions and 213 deletions

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -36,6 +37,7 @@ const formSchema = z.object({
ai_model: z.string(),
ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(),
anthropic_api_key: z.string().optional(),
openai_base_url: z.string().optional(),
})
@@ -48,6 +50,7 @@ interface AISettingsFormProps {
ai_model?: string
ai_send_descriptions?: string
openai_api_key?: string
anthropic_api_key?: string
openai_base_url?: string
}
}
@@ -63,12 +66,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '',
anthropic_api_key: '',
openai_base_url: settings.openai_base_url || '',
},
})
const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm'
const isAnthropic = watchProvider === 'anthropic'
const prevProviderRef = useRef(settings.ai_provider || 'openai')
// Auto-reset model when provider changes
useEffect(() => {
if (watchProvider !== prevProviderRef.current) {
prevProviderRef.current = watchProvider
if (watchProvider === 'anthropic') {
form.setValue('ai_model', 'claude-sonnet-4-5-20250514')
} else if (watchProvider === 'openai') {
form.setValue('ai_model', 'gpt-4o')
} else if (watchProvider === 'litellm') {
form.setValue('ai_model', '')
}
}
}, [watchProvider, form])
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
const {
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
}
if (data.anthropic_api_key && data.anthropic_api_key.trim()) {
settingsToUpdate.push({ key: 'anthropic_api_key', value: data.anthropic_api_key })
}
// Save base URL (empty string clears it)
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)
const categoryLabels: Record<string, string> = {
'claude-4.5': 'Claude 4.5 Series (Latest)',
'claude-4': 'Claude 4 Series',
'claude-3.5': 'Claude 3.5 Series',
'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
other: 'Other Models',
}
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
const categoryOrder = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return (
<Form {...form}>
@@ -187,13 +213,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="anthropic">Anthropic (Claude API)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{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'}
: field.value === 'anthropic'
? 'Direct Anthropic API access using Claude models'
: 'Direct OpenAI API access using your API key'}
</FormDescription>
<FormMessage />
</FormItem>
@@ -211,37 +240,71 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Alert>
)}
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
{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.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAnthropic && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>Anthropic Claude Mode</strong> AI calls use the Anthropic Messages API.
Claude Opus models include extended thinking for deeper analysis.
JSON responses are validated with automatic retry.
</AlertDescription>
</Alert>
)}
{isAnthropic ? (
<FormField
control={form.control}
name="anthropic_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Anthropic API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.anthropic_api_key ? '••••••••' : 'Enter Anthropic API key'}
{...field}
/>
</FormControl>
<FormDescription>
Your Anthropic API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
{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.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="openai_base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'}</FormLabel>
<FormControl>
<Input
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
@@ -255,6 +318,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
or your server address.
</>
) : isAnthropic ? (
<>
Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API.
</>
) : (
<>
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
@@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
</div>
{isLiteLLM || modelsData?.manualEntry ? (
{isAnthropic ? (
// Anthropic: fetch models from server (hardcoded list)
modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Claude model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
) : (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="claude-sonnet-4-5-20250514"
/>
)
) : isLiteLLM || modelsData?.manualEntry ? (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select>
)}
<FormDescription>
{isLiteLLM ? (
{isAnthropic ? (
form.watch('ai_model')?.includes('opus') ? (
<span className="flex items-center gap-1 text-amber-600">
<SlidersHorizontal className="h-3 w-3" />
Opus model includes extended thinking for deeper analysis
</span>
) : (
'Anthropic Claude model to use for AI features'
)
) : isLiteLLM ? (
<>
Enter the model ID with the{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.

View File

@@ -23,7 +23,6 @@ import {
Newspaper,
BarChart3,
ShieldAlert,
Globe,
Webhook,
MessageCircle,
} from 'lucide-react'
@@ -158,11 +157,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'whatsapp_provider',
])
const localizationSettings = getSettingsByKeys([
'localization_enabled_locales',
'localization_default_locale',
])
return (
<>
<Tabs defaultValue="defaults" className="space-y-6">
@@ -176,10 +170,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" />
Branding
</TabsTrigger>
<TabsTrigger value="localization" className="gap-2 shrink-0">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" />
@@ -253,10 +243,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" />
Branding
</TabsTrigger>
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
</TabsList>
</div>
<div>
@@ -510,22 +496,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard>
</TabsContent>
<TabsContent value="localization" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Localization</CardTitle>
<CardDescription>
Configure language and locale settings
</CardDescription>
</CardHeader>
<CardContent>
<LocalizationSettingsSection settings={localizationSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard>
@@ -858,66 +828,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record<string, string
)
}
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
const toggleLocale = (locale: string) => {
const current = new Set(enabledLocales)
if (current.has(locale)) {
if (current.size <= 1) {
toast.error('At least one locale must be enabled')
return
}
current.delete(locale)
} else {
current.add(locale)
}
mutation.mutate({
key: 'localization_enabled_locales',
value: Array.from(current).join(','),
})
}
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Enabled Languages</Label>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">EN</span>
<span className="text-sm text-muted-foreground">English</span>
</div>
<Checkbox
checked={enabledLocales.includes('en')}
onCheckedChange={() => toggleLocale('en')}
disabled={mutation.isPending}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">FR</span>
<span className="text-sm text-muted-foreground">Fran&ccedil;ais</span>
</div>
<Checkbox
checked={enabledLocales.includes('fr')}
onCheckedChange={() => toggleLocale('fr')}
disabled={mutation.isPending}
/>
</div>
</div>
</div>
<SettingSelect
label="Default Locale"
description="The default language for new users"
settingKey="localization_default_locale"
value={settings.localization_default_locale || 'en'}
options={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Fran\u00e7ais' },
]}
/>
</div>
)
}