Skip to content

Commit 10d4a3f

Browse files
msoginclaude
andcommitted
feat: LLM Settings tab with config display and connection test
New tab in Settings showing provider, model, concurrency (read-only). Status banner shows if config is complete or what's missing. Test Connection button sends a quick LLM call and reports success/latency or error. Setup instructions shown when config is incomplete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 06cf374 commit 10d4a3f

2 files changed

Lines changed: 239 additions & 1 deletion

File tree

src/app/api/llm-config/route.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { getLLMClient, LLM_MODEL, extraBodyProps } from '@/lib/llm-provider';
3+
4+
// GET — return current LLM config (no secrets)
5+
export async function GET() {
6+
const provider = process.env.LLM_PROVIDER || 'openai';
7+
const model = LLM_MODEL;
8+
const hasApiKey = Boolean(process.env.LLM_API_KEY);
9+
const baseUrl = process.env.LLM_BASE_URL || null;
10+
const concurrency = Number(process.env.LLM_CONCURRENCY || 5);
11+
12+
// Provider-specific config
13+
const config: Record<string, any> = { provider, model, hasApiKey, concurrency };
14+
15+
if (provider === 'openai') {
16+
config.endpoint = 'https://api.openai.com/v1';
17+
} else if (provider === 'anthropic') {
18+
config.endpoint = 'https://api.anthropic.com/v1';
19+
} else if (provider === 'openai-compatible') {
20+
config.endpoint = baseUrl || '(not set)';
21+
} else if (provider === 'smartling') {
22+
config.endpoint = process.env.SMARTLING_BASE_URL || '(not set)';
23+
config.hasAccountUid = Boolean(process.env.SMARTLING_ACCOUNT_UID);
24+
config.hasUserIdentifier = Boolean(process.env.SMARTLING_USER_IDENTIFIER);
25+
config.hasUserSecret = Boolean(process.env.SMARTLING_USER_SECRET);
26+
}
27+
28+
// Check what's missing
29+
const missing: string[] = [];
30+
if (!hasApiKey && provider !== 'smartling') missing.push('LLM_API_KEY');
31+
if (provider === 'openai-compatible' && !baseUrl) missing.push('LLM_BASE_URL');
32+
if (provider === 'smartling') {
33+
if (!process.env.SMARTLING_BASE_URL) missing.push('SMARTLING_BASE_URL');
34+
if (!process.env.SMARTLING_ACCOUNT_UID) missing.push('SMARTLING_ACCOUNT_UID');
35+
if (!process.env.SMARTLING_USER_IDENTIFIER) missing.push('SMARTLING_USER_IDENTIFIER');
36+
if (!process.env.SMARTLING_USER_SECRET) missing.push('SMARTLING_USER_SECRET');
37+
}
38+
39+
config.missing = missing;
40+
config.ready = missing.length === 0;
41+
42+
return NextResponse.json(config);
43+
}
44+
45+
// POST — test LLM connection
46+
export async function POST() {
47+
const start = Date.now();
48+
try {
49+
const client = await getLLMClient();
50+
const response = await client.chat.completions.create({
51+
model: LLM_MODEL,
52+
temperature: 0,
53+
max_tokens: 32,
54+
messages: [
55+
{ role: 'system', content: 'Reply with exactly: OK' },
56+
{ role: 'user', content: 'Test connection' },
57+
],
58+
...extraBodyProps(),
59+
} as any);
60+
61+
const content = response.choices[0]?.message?.content || '';
62+
const elapsed = Date.now() - start;
63+
64+
return NextResponse.json({
65+
success: true,
66+
response: content.trim(),
67+
model: response.model || LLM_MODEL,
68+
latencyMs: elapsed,
69+
});
70+
} catch (err) {
71+
const elapsed = Date.now() - start;
72+
return NextResponse.json({
73+
success: false,
74+
error: err instanceof Error ? err.message : String(err),
75+
latencyMs: elapsed,
76+
});
77+
}
78+
}

src/app/settings/page.tsx

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState, useEffect, useRef } from 'react';
44
import { useRouter } from 'next/navigation';
55

6-
type Tab = 'schedules' | 'teams';
6+
type Tab = 'schedules' | 'teams' | 'llm';
77

88
const CADENCE_PRESETS = [
99
{ label: 'Every hour', cron: '0 * * * *' },
@@ -64,6 +64,7 @@ export default function SettingsPage() {
6464
{([
6565
{ id: 'schedules' as Tab, label: 'Schedules', icon: '🕐' },
6666
{ id: 'teams' as Tab, label: 'Teams', icon: '👥' },
67+
{ id: 'llm' as Tab, label: 'LLM Settings', icon: '🤖' },
6768
]).map(tab => (
6869
<button
6970
key={tab.id}
@@ -83,6 +84,7 @@ export default function SettingsPage() {
8384
{/* Tab Content */}
8485
{activeTab === 'schedules' && <SchedulesTab />}
8586
{activeTab === 'teams' && selectedOrg && <TeamsTab org={selectedOrg} />}
87+
{activeTab === 'llm' && <LlmSettingsTab />}
8688
</div>
8789
);
8890
}
@@ -716,3 +718,161 @@ function TeamsTab({ org }: { org: string }) {
716718
</div>
717719
);
718720
}
721+
722+
/* ── LLM Settings Tab ── */
723+
const PROVIDER_INFO: Record<string, { name: string; docs: string; envVars: string[] }> = {
724+
openai: {
725+
name: 'OpenAI',
726+
docs: 'https://platform.openai.com/api-keys',
727+
envVars: ['LLM_PROVIDER=openai', 'LLM_API_KEY=sk-...', 'LLM_MODEL=gpt-4o'],
728+
},
729+
anthropic: {
730+
name: 'Anthropic',
731+
docs: 'https://console.anthropic.com/settings/keys',
732+
envVars: ['LLM_PROVIDER=anthropic', 'LLM_API_KEY=sk-ant-...', 'LLM_MODEL=claude-sonnet-4-20250514'],
733+
},
734+
'openai-compatible': {
735+
name: 'OpenAI-Compatible (Ollama, vLLM, Azure)',
736+
docs: '',
737+
envVars: ['LLM_PROVIDER=openai-compatible', 'LLM_BASE_URL=http://localhost:11434/v1', 'LLM_MODEL=llama3', 'LLM_API_KEY=not-needed'],
738+
},
739+
smartling: {
740+
name: 'Smartling AI Proxy',
741+
docs: '',
742+
envVars: ['LLM_PROVIDER=smartling', 'SMARTLING_BASE_URL=https://api.smartling.com', 'SMARTLING_ACCOUNT_UID=...', 'SMARTLING_USER_IDENTIFIER=...', 'SMARTLING_USER_SECRET=...', 'LLM_MODEL=anthropic/claude-sonnet-4-20250514'],
743+
},
744+
};
745+
746+
function LlmSettingsTab() {
747+
const [config, setConfig] = useState<any>(null);
748+
const [loading, setLoading] = useState(true);
749+
const [testing, setTesting] = useState(false);
750+
const [testResult, setTestResult] = useState<any>(null);
751+
752+
useEffect(() => {
753+
fetch('/api/llm-config').then(r => r.json()).then(setConfig).catch(() => {}).finally(() => setLoading(false));
754+
}, []);
755+
756+
async function testConnection() {
757+
setTesting(true);
758+
setTestResult(null);
759+
try {
760+
const res = await fetch('/api/llm-config', { method: 'POST' });
761+
const data = await res.json();
762+
setTestResult(data);
763+
} catch {
764+
setTestResult({ success: false, error: 'Network error' });
765+
}
766+
setTesting(false);
767+
}
768+
769+
if (loading) return <div className="text-gray-500 text-sm py-8">Loading configuration...</div>;
770+
if (!config) return <div className="text-red-400 text-sm py-8">Failed to load configuration.</div>;
771+
772+
const info = PROVIDER_INFO[config.provider] || PROVIDER_INFO['openai'];
773+
774+
return (
775+
<div>
776+
<p className="text-sm text-gray-400 mb-6">Current LLM configuration (read-only). Edit <code className="text-xs bg-gray-800 px-1.5 py-0.5 rounded">.env.local</code> to change settings.</p>
777+
778+
{/* Status */}
779+
<div className={`rounded-xl p-4 mb-6 flex items-center gap-3 ${config.ready ? 'bg-green-500/10 border border-green-500/20' : 'bg-amber-500/10 border border-amber-500/20'}`}>
780+
<span className="text-xl">{config.ready ? '✅' : '⚠️'}</span>
781+
<div>
782+
<p className={`text-sm font-semibold ${config.ready ? 'text-green-400' : 'text-amber-400'}`}>
783+
{config.ready ? 'LLM is configured and ready' : 'Configuration incomplete'}
784+
</p>
785+
{!config.ready && (
786+
<p className="text-xs text-gray-500 mt-0.5">
787+
Missing: {config.missing.map((v: string) => <code key={v} className="bg-gray-800 px-1 py-0.5 rounded text-amber-300 mx-0.5">{v}</code>)}
788+
</p>
789+
)}
790+
</div>
791+
</div>
792+
793+
{/* Config details */}
794+
<div className="bg-gray-900 rounded-xl p-5 mb-6">
795+
<div className="grid grid-cols-3 gap-4">
796+
<ConfigRow label="Provider" value={info.name} />
797+
<ConfigRow label="Model" value={config.model} />
798+
<ConfigRow label="Concurrency" value={String(config.concurrency)} />
799+
</div>
800+
</div>
801+
802+
{/* Test connection */}
803+
<div className="bg-gray-900 rounded-xl p-5 mb-6">
804+
<div className="flex items-center justify-between mb-3">
805+
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold">Connection Test</p>
806+
<button
807+
onClick={testConnection}
808+
disabled={testing || !config.ready}
809+
className="px-3 py-1.5 text-xs font-medium bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg transition-colors flex items-center gap-2"
810+
>
811+
{testing && (
812+
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
813+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
814+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
815+
</svg>
816+
)}
817+
{testing ? 'Testing...' : 'Test Connection'}
818+
</button>
819+
</div>
820+
{testResult && (
821+
<div className={`rounded-lg p-3 text-sm ${testResult.success ? 'bg-green-500/10 border border-green-500/20' : 'bg-red-500/10 border border-red-500/20'}`}>
822+
{testResult.success ? (
823+
<div className="flex items-center gap-2 flex-wrap">
824+
<span className="text-green-400 font-semibold">Success</span>
825+
<span className="text-gray-500">·</span>
826+
<span className="text-gray-400">Model: {testResult.model}</span>
827+
<span className="text-gray-500">·</span>
828+
<span className="text-gray-400">{testResult.latencyMs}ms</span>
829+
<span className="text-gray-500">·</span>
830+
<span className="text-gray-500 font-mono text-xs">&quot;{testResult.response}&quot;</span>
831+
</div>
832+
) : (
833+
<div>
834+
<span className="text-red-400 font-semibold">Failed</span>
835+
<span className="text-gray-500 ml-2">({testResult.latencyMs}ms)</span>
836+
<p className="text-xs text-red-300/70 mt-1 font-mono">{testResult.error}</p>
837+
</div>
838+
)}
839+
</div>
840+
)}
841+
{!testResult && !testing && (
842+
<p className="text-xs text-gray-600">Sends a simple test message to verify the LLM connection is working.</p>
843+
)}
844+
</div>
845+
846+
{/* Setup instructions */}
847+
{!config.ready && (
848+
<div className="bg-gray-900 rounded-xl p-5">
849+
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3">Setup Instructions</p>
850+
<p className="text-sm text-gray-400 mb-3">
851+
Add the following to your <code className="text-xs bg-gray-800 px-1.5 py-0.5 rounded">.env.local</code> file:
852+
</p>
853+
<pre className="bg-gray-950 rounded-lg p-4 text-xs text-gray-300 font-mono leading-relaxed overflow-x-auto">
854+
{info.envVars.join('\n')}
855+
</pre>
856+
{info.docs && (
857+
<p className="text-xs text-gray-500 mt-3">
858+
Get your API key: <a href={info.docs} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300">{info.docs}</a>
859+
</p>
860+
)}
861+
</div>
862+
)}
863+
</div>
864+
);
865+
}
866+
867+
function ConfigRow({ label, value, status }: { label: string; value: string; status?: 'ok' | 'missing' }) {
868+
return (
869+
<div>
870+
<p className="text-xs text-gray-500 mb-0.5">{label}</p>
871+
<div className="flex items-center gap-2">
872+
<p className="text-sm text-white font-mono">{value}</p>
873+
{status === 'ok' && <span className="w-1.5 h-1.5 rounded-full bg-green-400" />}
874+
{status === 'missing' && <span className="w-1.5 h-1.5 rounded-full bg-amber-400" />}
875+
</div>
876+
</div>
877+
);
878+
}

0 commit comments

Comments
 (0)