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:
parent
14f63a3e0c
commit
d05480610d
5 changed files with 449 additions and 0 deletions
|
|
@ -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},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
307
src/components/admin/AIConfigTab.tsx
Normal file
307
src/components/admin/AIConfigTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue