diff --git a/backend/app/models/app_settings.py b/backend/app/models/app_settings.py index eb5d167f..27bf0ec8 100644 --- a/backend/app/models/app_settings.py +++ b/backend/app/models/app_settings.py @@ -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}, + ], + }, + ], } diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index ada4705b..4a843e9b 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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 diff --git a/src/components/admin/AIConfigTab.tsx b/src/components/admin/AIConfigTab.tsx new file mode 100644 index 00000000..bcceeba2 --- /dev/null +++ b/src/components/admin/AIConfigTab.tsx @@ -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(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
Loading…
; + } + + 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 ( +
+ {/* Provider selector */} +
+

Active provider

+ +
+ + {activeProvider && ( + <> + {/* Provider settings */} +
+

Provider settings

+
+
+ + updateProvider('endpoint', e.target.value)} + placeholder="https://..." + className="font-mono text-xs" + /> +
+
+ +
+
+ setDirtyKey(e.target.value)} + placeholder="Enter new key to update" + className="font-mono text-xs pr-9" + /> + +
+ +
+ {testResult && ( +
+ {testResult.ok + ? <> Connected · {testResult.latency_ms}ms + : <> {testResult.error} + } +
+ )} +
+
+
+ + {/* Models table */} +
+

Models

+
+ + + + + + + + + + + {activeProvider.models.map(m => ( + + + + + + + ))} + +
NameModel IDRoleEnabled
{m.display_name}{m.id} + + + updateModel(m.id, { ...m, enabled: v })} + /> +
+
+
+ + {/* Routing */} +
+

Routing

+
+
+ + +
+
+ + +
+
+
+ + )} + + +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index ccb8b2e9..e2cf4439 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 }), diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 85feb19f..32ee72bc 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -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() { Usage Model Pricing Focus Groups + AI Config @@ -59,6 +61,10 @@ export default function Admin() { + + + +