feat(admin): AI model catalog — provider config, model routing, test connection

- app_settings.py: add ai_providers[], active_provider, active_main/mini_model to DEFAULTS
- admin.py: GET/PUT /api/admin/ai-config (API key masked on read, preserved if not updated)
           POST /api/admin/ai-config/test (latency + connection check)
- AIConfigTab.tsx: provider dropdown, endpoint/key fields, models table with role+enabled toggles,
                   main/mini routing selects, "Test connection" with live latency feedback
- Admin.tsx: add "AI Config" tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-24 14:32:23 +01:00
parent 14f63a3e0c
commit d05480610d
5 changed files with 449 additions and 0 deletions

View file

@ -24,6 +24,22 @@ DEFAULTS = {
{"id": "pro", "name": "Pro", "price_usd": 199, "credits": 220, "popular": True},
{"id": "scale", "name": "Scale", "price_usd": 499, "credits": 600},
],
"active_provider": "azure_openai",
"active_main_model": "gpt-5.4",
"active_mini_model": "gpt-5.4-mini",
"ai_providers": [
{
"id": "azure_openai",
"name": "Azure OpenAI",
"enabled": True,
"endpoint": "",
"api_key": "",
"models": [
{"id": "gpt-5.4", "display_name": "GPT-5.4", "role": "main", "enabled": True},
{"id": "gpt-5.4-mini", "display_name": "GPT-5.4 Mini", "role": "mini", "enabled": True},
],
},
],
}

View file

@ -643,3 +643,117 @@ async def get_analytics():
'model_breakdown': make_serializable(model_agg),
'daily_purchases': make_serializable(daily_purchases),
}), 200
# ─────────────────────────────────────────────────────────────────────────────
# AI Config
# ─────────────────────────────────────────────────────────────────────────────
def _mask_key(key: str) -> str:
"""Return masked version of API key for display."""
if not key:
return ''
if len(key) <= 8:
return '***'
return key[:4] + '***' + key[-4:]
@admin_bp.route('/ai-config', methods=['GET'])
@jwt_required()
@admin_required
async def get_ai_config():
"""GET /api/admin/ai-config — return current AI provider config (API keys masked)."""
settings = await get_settings()
providers = settings.get('ai_providers', [])
masked = []
for p in providers:
mp = {**p}
mp['api_key'] = _mask_key(p.get('api_key', ''))
masked.append(mp)
return jsonify({
'active_provider': settings.get('active_provider', 'azure_openai'),
'active_main_model': settings.get('active_main_model', 'gpt-5.4'),
'active_mini_model': settings.get('active_mini_model', 'gpt-5.4-mini'),
'ai_providers': masked,
}), 200
@admin_bp.route('/ai-config', methods=['PUT'])
@jwt_required()
@admin_required
async def update_ai_config():
"""PUT /api/admin/ai-config — update AI provider config.
API key is only updated when passed non-empty.
"""
body = await request.get_json() or {}
settings = await get_settings()
existing_providers = {p['id']: p for p in settings.get('ai_providers', [])}
update: dict = {}
if 'active_provider' in body:
update['active_provider'] = body['active_provider']
if 'active_main_model' in body:
update['active_main_model'] = body['active_main_model']
if 'active_mini_model' in body:
update['active_mini_model'] = body['active_mini_model']
if 'ai_providers' in body:
merged = []
for p in body['ai_providers']:
pid = p.get('id')
existing = existing_providers.get(pid, {})
mp = {**existing, **p}
# Only replace key if a non-empty, non-masked value is supplied
new_key = p.get('api_key', '')
if not new_key or '***' in new_key:
mp['api_key'] = existing.get('api_key', '')
merged.append(mp)
update['ai_providers'] = merged
await update_settings(update)
return jsonify({'ok': True}), 200
@admin_bp.route('/ai-config/test', methods=['POST'])
@jwt_required()
@admin_required
async def test_ai_config():
"""POST /api/admin/ai-config/test — send a minimal request to the configured endpoint."""
import time
from openai import AsyncOpenAI
body = await request.get_json() or {}
settings = await get_settings()
providers = {p['id']: p for p in settings.get('ai_providers', [])}
provider_id = body.get('provider_id') or settings.get('active_provider', 'azure_openai')
provider = providers.get(provider_id, {})
endpoint = body.get('endpoint') or provider.get('endpoint', '')
api_key = body.get('api_key', '')
# If masked or empty, fall back to stored key
if not api_key or '***' in api_key:
api_key = provider.get('api_key', '')
# Final fallback: environment variable
if not api_key:
import os
api_key = os.environ.get('AZURE_AI_API_KEY', '')
if not endpoint:
import os
endpoint = os.environ.get('AZURE_AI_ENDPOINT', '')
model = settings.get('active_main_model', 'gpt-5.4')
try:
client = AsyncOpenAI(api_key=api_key, base_url=endpoint)
t0 = time.monotonic()
resp = await client.chat.completions.create(
model=model,
messages=[{'role': 'user', 'content': 'Reply with the word OK only.'}],
max_tokens=5,
)
latency_ms = round((time.monotonic() - t0) * 1000)
reply = resp.choices[0].message.content.strip() if resp.choices else ''
return jsonify({'ok': True, 'latency_ms': latency_ms, 'reply': reply}), 200
except Exception as exc:
return jsonify({'ok': False, 'error': str(exc)}), 200

View file

@ -0,0 +1,307 @@
import { useState, useEffect, useCallback } from 'react';
import { adminApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { Loader2, CheckCircle2, XCircle, Eye, EyeOff } from 'lucide-react';
interface AIModel {
id: string;
display_name: string;
role: 'main' | 'mini' | '';
enabled: boolean;
}
interface AIProvider {
id: string;
name: string;
enabled: boolean;
endpoint: string;
api_key: string;
models: AIModel[];
}
interface AIConfig {
active_provider: string;
active_main_model: string;
active_mini_model: string;
ai_providers: AIProvider[];
}
export default function AIConfigTab() {
const [config, setConfig] = useState<AIConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; latency_ms?: number; error?: string } | null>(null);
const [showKey, setShowKey] = useState(false);
const [dirtyKey, setDirtyKey] = useState('');
const load = useCallback(async () => {
try {
const res = await adminApi.getAiConfig();
setConfig(res.data);
setDirtyKey('');
} catch {
toast.error('Failed to load AI config');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
if (loading || !config) {
return <div className="py-12 text-center text-muted-foreground text-sm">Loading</div>;
}
const activeProvider = config.ai_providers.find(p => p.id === config.active_provider);
const updateProvider = (field: keyof AIProvider, value: any) => {
setConfig(prev => {
if (!prev) return prev;
return {
...prev,
ai_providers: prev.ai_providers.map(p =>
p.id === prev.active_provider ? { ...p, [field]: value } : p,
),
};
});
};
const updateModel = (modelId: string, updated: AIModel) => {
setConfig(prev => {
if (!prev) return prev;
return {
...prev,
ai_providers: prev.ai_providers.map(p =>
p.id === prev.active_provider
? { ...p, models: p.models.map(m => (m.id === modelId ? updated : m)) }
: p,
),
};
});
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
active_provider: config.active_provider,
active_main_model: config.active_main_model,
active_mini_model: config.active_mini_model,
ai_providers: config.ai_providers.map(p =>
p.id === config.active_provider && dirtyKey
? { ...p, api_key: dirtyKey }
: p,
),
};
await adminApi.updateAiConfig(payload);
toast.success('AI config saved');
setDirtyKey('');
load();
} catch {
toast.error('Failed to save');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await adminApi.testAiConfig({
provider_id: config.active_provider,
api_key: dirtyKey || undefined,
});
setTestResult(res.data);
} catch {
setTestResult({ ok: false, error: 'Request failed' });
} finally {
setTesting(false);
}
};
const enabledModels = activeProvider?.models.filter(m => m.enabled) ?? [];
return (
<div className="space-y-8">
{/* Provider selector */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">Active provider</h3>
<Select
value={config.active_provider}
onValueChange={v => setConfig(prev => prev ? { ...prev, active_provider: v } : prev)}
>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.ai_providers.map(p => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{activeProvider && (
<>
{/* Provider settings */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Provider settings</h3>
<div className="grid grid-cols-1 gap-4 max-w-lg">
<div className="space-y-1.5">
<Label>Endpoint URL</Label>
<Input
value={activeProvider.endpoint}
onChange={e => updateProvider('endpoint', e.target.value)}
placeholder="https://..."
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label>API Key</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
value={dirtyKey || activeProvider.api_key}
onChange={e => setDirtyKey(e.target.value)}
placeholder="Enter new key to update"
className="font-mono text-xs pr-9"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTest}
disabled={testing}
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
</Button>
</div>
{testResult && (
<div className={`flex items-center gap-1.5 text-xs mt-1 ${testResult.ok ? 'text-green-500' : 'text-destructive'}`}>
{testResult.ok
? <><CheckCircle2 className="h-3.5 w-3.5" /> Connected · {testResult.latency_ms}ms</>
: <><XCircle className="h-3.5 w-3.5" /> {testResult.error}</>
}
</div>
)}
</div>
</div>
</div>
{/* Models table */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left py-2 px-4 text-xs font-medium text-muted-foreground">Name</th>
<th className="text-left py-2 pr-4 text-xs font-medium text-muted-foreground">Model ID</th>
<th className="text-left py-2 pr-4 text-xs font-medium text-muted-foreground">Role</th>
<th className="text-left py-2 text-xs font-medium text-muted-foreground">Enabled</th>
</tr>
</thead>
<tbody className="px-4">
{activeProvider.models.map(m => (
<tr key={m.id} className="border-t border-border">
<td className="py-2.5 px-4 text-sm font-medium text-foreground">{m.display_name}</td>
<td className="py-2.5 pr-4 text-xs text-muted-foreground font-mono">{m.id}</td>
<td className="py-2.5 pr-4">
<Select
value={m.role}
onValueChange={v => updateModel(m.id, { ...m, role: v as AIModel['role'] })}
>
<SelectTrigger className="h-7 text-xs w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="main">Main</SelectItem>
<SelectItem value="mini">Mini</SelectItem>
<SelectItem value=""></SelectItem>
</SelectContent>
</Select>
</td>
<td className="py-2.5">
<Switch
checked={m.enabled}
onCheckedChange={v => updateModel(m.id, { ...m, enabled: v })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Routing */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Routing</h3>
<div className="grid grid-cols-2 gap-4 max-w-lg">
<div className="space-y-1.5">
<Label>Main model</Label>
<Select
value={config.active_main_model}
onValueChange={v => setConfig(prev => prev ? { ...prev, active_main_model: v } : prev)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledModels.map(m => (
<SelectItem key={m.id} value={m.id}>{m.display_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Mini model</Label>
<Select
value={config.active_mini_model}
onValueChange={v => setConfig(prev => prev ? { ...prev, active_mini_model: v } : prev)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{enabledModels.map(m => (
<SelectItem key={m.id} value={m.id}>{m.display_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</>
)}
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Save configuration
</Button>
</div>
);
}

View file

@ -760,6 +760,12 @@ export const adminApi = {
getSettings: () => api.get('/admin/settings'),
updateSettings: (data: any) => api.put('/admin/settings', data),
// AI config
getAiConfig: () => api.get('/admin/ai-config'),
updateAiConfig: (data: any) => api.put('/admin/ai-config', data),
testAiConfig: (data: { provider_id?: string; endpoint?: string; api_key?: string }) =>
api.post('/admin/ai-config/test', data),
// Manual credit adjustment
adjustCredits: (userId: string, amount: number, reason: string) =>
api.post(`/admin/users/${userId}/credits`, { amount, reason }),

View file

@ -8,6 +8,7 @@ import PricingTab from '@/components/admin/PricingTab';
import FocusGroupsTab from '@/components/admin/FocusGroupsTab';
import AnalyticsTab from '@/components/admin/AnalyticsTab';
import CreditSettingsTab from '@/components/admin/CreditSettingsTab';
import AIConfigTab from '@/components/admin/AIConfigTab';
export default function Admin() {
const navigate = useNavigate();
@ -34,6 +35,7 @@ export default function Admin() {
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="pricing">Model Pricing</TabsTrigger>
<TabsTrigger value="focus-groups">Focus Groups</TabsTrigger>
<TabsTrigger value="ai-config">AI Config</TabsTrigger>
</TabsList>
<TabsContent value="users">
@ -59,6 +61,10 @@ export default function Admin() {
<TabsContent value="focus-groups">
<FocusGroupsTab />
</TabsContent>
<TabsContent value="ai-config">
<AIConfigTab />
</TabsContent>
</Tabs>
</div>
</div>