@darkrishabh/bench-ai 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +333 -0
  2. package/dist/cli/app.d.ts +11 -0
  3. package/dist/cli/app.d.ts.map +1 -0
  4. package/dist/cli/app.js +48 -0
  5. package/dist/cli/app.js.map +1 -0
  6. package/dist/cli/components/DiffView.d.ts +5 -0
  7. package/dist/cli/components/DiffView.d.ts.map +1 -0
  8. package/dist/cli/components/DiffView.js +14 -0
  9. package/dist/cli/components/DiffView.js.map +1 -0
  10. package/dist/cli/components/EvalView.d.ts +6 -0
  11. package/dist/cli/components/EvalView.d.ts.map +1 -0
  12. package/dist/cli/components/EvalView.js +82 -0
  13. package/dist/cli/components/EvalView.js.map +1 -0
  14. package/dist/cli/components/Spinner.d.ts +4 -0
  15. package/dist/cli/components/Spinner.d.ts.map +1 -0
  16. package/dist/cli/components/Spinner.js +15 -0
  17. package/dist/cli/components/Spinner.js.map +1 -0
  18. package/dist/cli/index.d.ts +3 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +117 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/run-command.d.ts +11 -0
  23. package/dist/cli/run-command.d.ts.map +1 -0
  24. package/dist/cli/run-command.js +119 -0
  25. package/dist/cli/run-command.js.map +1 -0
  26. package/dist/engine/cost.d.ts +3 -0
  27. package/dist/engine/cost.d.ts.map +1 -0
  28. package/dist/engine/cost.js +52 -0
  29. package/dist/engine/cost.js.map +1 -0
  30. package/dist/engine/diff.d.ts +6 -0
  31. package/dist/engine/diff.d.ts.map +1 -0
  32. package/dist/engine/diff.js +43 -0
  33. package/dist/engine/diff.js.map +1 -0
  34. package/dist/engine/eval.d.ts +14 -0
  35. package/dist/engine/eval.d.ts.map +1 -0
  36. package/dist/engine/eval.js +194 -0
  37. package/dist/engine/eval.js.map +1 -0
  38. package/dist/engine/index.d.ts +15 -0
  39. package/dist/engine/index.d.ts.map +1 -0
  40. package/dist/engine/index.js +10 -0
  41. package/dist/engine/index.js.map +1 -0
  42. package/dist/engine/providers/base.d.ts +7 -0
  43. package/dist/engine/providers/base.d.ts.map +1 -0
  44. package/dist/engine/providers/base.js +2 -0
  45. package/dist/engine/providers/base.js.map +1 -0
  46. package/dist/engine/providers/claude.d.ts +15 -0
  47. package/dist/engine/providers/claude.d.ts.map +1 -0
  48. package/dist/engine/providers/claude.js +53 -0
  49. package/dist/engine/providers/claude.js.map +1 -0
  50. package/dist/engine/providers/minimax.d.ts +16 -0
  51. package/dist/engine/providers/minimax.d.ts.map +1 -0
  52. package/dist/engine/providers/minimax.js +67 -0
  53. package/dist/engine/providers/minimax.js.map +1 -0
  54. package/dist/engine/providers/ollama.d.ts +14 -0
  55. package/dist/engine/providers/ollama.d.ts.map +1 -0
  56. package/dist/engine/providers/ollama.js +60 -0
  57. package/dist/engine/providers/ollama.js.map +1 -0
  58. package/dist/engine/providers/openai-compatible.d.ts +19 -0
  59. package/dist/engine/providers/openai-compatible.d.ts.map +1 -0
  60. package/dist/engine/providers/openai-compatible.js +109 -0
  61. package/dist/engine/providers/openai-compatible.js.map +1 -0
  62. package/dist/engine/providers/subprocess.d.ts +55 -0
  63. package/dist/engine/providers/subprocess.d.ts.map +1 -0
  64. package/dist/engine/providers/subprocess.js +111 -0
  65. package/dist/engine/providers/subprocess.js.map +1 -0
  66. package/dist/engine/suite-loader.d.ts +11 -0
  67. package/dist/engine/suite-loader.d.ts.map +1 -0
  68. package/dist/engine/suite-loader.js +75 -0
  69. package/dist/engine/suite-loader.js.map +1 -0
  70. package/dist/engine/types.d.ts +104 -0
  71. package/dist/engine/types.d.ts.map +1 -0
  72. package/dist/engine/types.js +2 -0
  73. package/dist/engine/types.js.map +1 -0
  74. package/next-env.d.ts +6 -0
  75. package/next.config.ts +26 -0
  76. package/package.json +72 -0
  77. package/public/icon.svg +14 -0
  78. package/src/app/api/diff/route.ts +135 -0
  79. package/src/app/api/models/route.ts +96 -0
  80. package/src/app/api/suite/route.ts +314 -0
  81. package/src/app/globals.css +215 -0
  82. package/src/app/icon.svg +14 -0
  83. package/src/app/layout.tsx +44 -0
  84. package/src/app/opengraph-image.tsx +73 -0
  85. package/src/app/page.tsx +952 -0
  86. package/src/app/suite/layout.tsx +12 -0
  87. package/src/app/suite/page.tsx +206 -0
  88. package/src/app/twitter-image.tsx +1 -0
  89. package/src/components/BenchAiLogo.tsx +38 -0
  90. package/src/components/ComparePanel.tsx +643 -0
  91. package/src/components/ConfigPanel.tsx +809 -0
  92. package/src/components/MarkdownOutput.tsx +16 -0
  93. package/src/components/ModelResponseCard.tsx +313 -0
  94. package/src/components/QuickComparisonBar.tsx +184 -0
  95. package/src/components/ResponsesLineDiff.tsx +149 -0
  96. package/src/components/SettingsPanel.tsx +591 -0
  97. package/src/components/SuitePanel.tsx +875 -0
  98. package/src/lib/brand.ts +4 -0
  99. package/src/lib/config-yaml.ts +70 -0
  100. package/src/lib/consume-suite-sse.ts +70 -0
  101. package/src/lib/describe-judge.ts +23 -0
  102. package/src/lib/model-chip-palette.ts +9 -0
  103. package/src/lib/openai-model-list.ts +33 -0
  104. package/src/lib/provider-ui.ts +30 -0
  105. package/src/lib/resolve-credentials.ts +80 -0
  106. package/src/lib/run-history.ts +66 -0
  107. package/src/lib/simple-line-diff.ts +50 -0
  108. package/src/lib/storage.ts +100 -0
  109. package/src/lib/suite-judge-meta.ts +13 -0
  110. package/src/lib/suite-run-history.ts +81 -0
  111. package/src/types.ts +170 -0
  112. package/vercel.json +5 -0
@@ -0,0 +1,809 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import type { LLMInstance, AnyProvider, NativeProvider, OAIPreset } from "../types";
5
+ import { PRESET_BASE_URLS, PRESET_MODELS } from "../types";
6
+
7
+ // ─── Providers with a fixed model list (no free-text input allowed) ───────────
8
+ const FIXED_MODEL_PROVIDERS = new Set<AnyProvider>(["claude-cli", "codex"]);
9
+
10
+ function useModels(
11
+ provider: AnyProvider,
12
+ baseUrl?: string,
13
+ apiKey?: string
14
+ ): { models: string[]; loading: boolean; error: string | null } {
15
+ const [models, setModels] = useState<string[]>(() => PRESET_MODELS[provider] ?? []);
16
+ const [loading, setLoading] = useState(false);
17
+ const [error, setError] = useState<string | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (provider === "ollama") {
21
+ let cancelled = false;
22
+ setLoading(true);
23
+ setError(null);
24
+
25
+ const params = new URLSearchParams({ provider: "ollama" });
26
+ if (baseUrl) params.set("baseUrl", baseUrl);
27
+
28
+ fetch(`/api/models?${params}`)
29
+ .then((r) => r.json() as Promise<{ models?: string[]; error?: string }>)
30
+ .then((data) => {
31
+ if (cancelled) return;
32
+ if (data.error) {
33
+ setError(data.error);
34
+ setModels(PRESET_MODELS.ollama ?? []);
35
+ } else {
36
+ setModels(data.models ?? []);
37
+ }
38
+ })
39
+ .catch((err: unknown) => {
40
+ if (!cancelled) {
41
+ setError(err instanceof Error ? err.message : String(err));
42
+ setModels(PRESET_MODELS.ollama ?? []);
43
+ }
44
+ })
45
+ .finally(() => { if (!cancelled) setLoading(false); });
46
+
47
+ return () => { cancelled = true; };
48
+ }
49
+
50
+ if (provider === "openai") {
51
+ if (!apiKey?.trim()) {
52
+ setModels(PRESET_MODELS.openai ?? []);
53
+ setError(null);
54
+ setLoading(false);
55
+ return;
56
+ }
57
+
58
+ let cancelled = false;
59
+ setLoading(true);
60
+ setError(null);
61
+
62
+ fetch("/api/models", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ provider: "openai",
67
+ apiKey: apiKey.trim(),
68
+ baseUrl: baseUrl?.trim() || undefined,
69
+ }),
70
+ })
71
+ .then((r) => r.json() as Promise<{ models?: string[]; error?: string; source?: string }>)
72
+ .then((data) => {
73
+ if (cancelled) return;
74
+ if (data.error) setError(data.error);
75
+ else setError(null);
76
+ setModels(data.models?.length ? data.models : (PRESET_MODELS.openai ?? []));
77
+ })
78
+ .catch((err: unknown) => {
79
+ if (!cancelled) {
80
+ setError(err instanceof Error ? err.message : String(err));
81
+ setModels(PRESET_MODELS.openai ?? []);
82
+ }
83
+ })
84
+ .finally(() => { if (!cancelled) setLoading(false); });
85
+
86
+ return () => { cancelled = true; };
87
+ }
88
+
89
+ setModels(PRESET_MODELS[provider] ?? []);
90
+ setError(null);
91
+ setLoading(false);
92
+ return undefined;
93
+ }, [provider, baseUrl, apiKey]);
94
+
95
+ return { models, loading, error };
96
+ }
97
+
98
+ // ─── Preset catalogue (monochrome UI — no per-provider chroma) ────────────────
99
+
100
+ interface PresetMeta {
101
+ label: string;
102
+ tag: string;
103
+ isNative?: boolean;
104
+ needsGroupId?: boolean;
105
+ noApiKey?: boolean;
106
+ noBaseUrl?: boolean;
107
+ }
108
+
109
+ const PRESETS: Record<AnyProvider, PresetMeta> = {
110
+ claude: { label: "Claude", tag: "Anthropic", isNative: true, noBaseUrl: true },
111
+ ollama: { label: "Ollama", tag: "Local", isNative: true, noApiKey: true },
112
+ minimax: { label: "Minimax", tag: "Minimax AI", isNative: true, noBaseUrl: true, needsGroupId: true },
113
+ "claude-cli":{ label: "Claude CLI", tag: "Local", isNative: true, noApiKey: true, noBaseUrl: true },
114
+ codex: { label: "Codex CLI", tag: "Local", isNative: true, noApiKey: true, noBaseUrl: true },
115
+ openai: { label: "OpenAI", tag: "GPT" },
116
+ groq: { label: "Groq", tag: "Fast" },
117
+ openrouter: { label: "OpenRouter", tag: "Multi" },
118
+ "nvidia-nim":{ label: "NVIDIA NIM", tag: "NIM" },
119
+ together: { label: "Together", tag: "Open models" },
120
+ perplexity: { label: "Perplexity", tag: "Search" },
121
+ custom: { label: "Custom", tag: "OpenAI-compat" },
122
+ };
123
+
124
+ const PRESET_ORDER: AnyProvider[] = [
125
+ "claude", "claude-cli", "codex",
126
+ "openai", "ollama",
127
+ "groq", "openrouter", "nvidia-nim",
128
+ "together", "perplexity", "minimax",
129
+ "custom",
130
+ ];
131
+
132
+ // ─── Factory ──────────────────────────────────────────────────────────────────
133
+
134
+ function makeInstance(provider: AnyProvider): LLMInstance {
135
+ const suggestions = PRESET_MODELS[provider];
136
+ const baseUrl = !PRESETS[provider].isNative || provider === "ollama"
137
+ ? (PRESET_BASE_URLS[provider as OAIPreset] ?? "")
138
+ : undefined;
139
+
140
+ return {
141
+ id: `${provider}-${Date.now()}`,
142
+ provider,
143
+ model: suggestions[0] ?? "",
144
+ enabled: true,
145
+ baseUrl: baseUrl || (provider === "ollama" ? "http://localhost:11434" : undefined),
146
+ maxTokens: provider !== "ollama" ? 2048 : undefined,
147
+ temperature: 0.7,
148
+ };
149
+ }
150
+
151
+ // ─── Field helpers ────────────────────────────────────────────────────────────
152
+
153
+ function showApiKey(provider: AnyProvider): boolean {
154
+ return !PRESETS[provider].noApiKey;
155
+ }
156
+ function showBaseUrl(provider: AnyProvider): boolean {
157
+ return !PRESETS[provider].noBaseUrl;
158
+ }
159
+ function showGroupId(provider: AnyProvider): boolean {
160
+ return !!PRESETS[provider].needsGroupId;
161
+ }
162
+ function showMaxTokens(provider: AnyProvider): boolean {
163
+ return provider !== "ollama";
164
+ }
165
+
166
+ // ─── Monochrome tokens (Cursor-like: neutral, quiet) ──────────────────────────
167
+
168
+ const inputStyle: React.CSSProperties = {
169
+ background: "var(--surface)",
170
+ border: "1px solid var(--border)",
171
+ borderRadius: "var(--r-md)",
172
+ color: "var(--text-1)",
173
+ padding: "0.45rem 0.65rem",
174
+ fontSize: "0.8125rem",
175
+ outline: "none",
176
+ width: "100%",
177
+ fontFamily: "inherit",
178
+ transition: "border-color 0.12s ease, box-shadow 0.12s ease",
179
+ };
180
+
181
+ function Field({
182
+ label,
183
+ labelExtra,
184
+ children,
185
+ }: {
186
+ label: string;
187
+ labelExtra?: React.ReactNode;
188
+ children: React.ReactNode;
189
+ }) {
190
+ return (
191
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
192
+ <div style={{ display: "flex", alignItems: "center", gap: "0.4rem", flexWrap: "wrap" }}>
193
+ <label style={{ color: "var(--text-3)", fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em" }}>
194
+ {label}
195
+ </label>
196
+ {labelExtra}
197
+ </div>
198
+ {children}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function SourceBadge({ children, tone }: { children: React.ReactNode; tone: "inline" | "variable" }) {
204
+ const isInline = tone === "inline";
205
+ return (
206
+ <span
207
+ style={{
208
+ fontSize: "0.58rem",
209
+ fontWeight: 700,
210
+ textTransform: "uppercase",
211
+ letterSpacing: "0.06em",
212
+ padding: "0.12rem 0.38rem",
213
+ borderRadius: 4,
214
+ background: isInline ? "var(--green-subtle)" : "var(--surface-muted)",
215
+ color: isInline ? "var(--green)" : "var(--text-2)",
216
+ border: `1px solid ${isInline ? "rgba(4, 120, 87, 0.25)" : "var(--border)"}`,
217
+ }}
218
+ >
219
+ {children}
220
+ </span>
221
+ );
222
+ }
223
+
224
+ function SectionLabel({ children }: { children: React.ReactNode }) {
225
+ return (
226
+ <div
227
+ style={{
228
+ fontSize: "0.65rem",
229
+ fontWeight: 600,
230
+ color: "var(--text-3)",
231
+ textTransform: "uppercase",
232
+ letterSpacing: "0.08em",
233
+ marginBottom: "0.45rem",
234
+ }}
235
+ >
236
+ {children}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ /** Provider initials for avatar tile (e.g. NVIDIA NIM → NV). */
242
+ function providerInitials(label: string): string {
243
+ const parts = label.split(/\s+/).filter(Boolean);
244
+ if (parts.length >= 2) {
245
+ const a = parts[0][0] ?? "";
246
+ const b = parts[1][0] ?? "";
247
+ return (a + b).toUpperCase();
248
+ }
249
+ return label.slice(0, 2).toUpperCase();
250
+ }
251
+
252
+ function GreenToggle({ on, onChange }: { on: boolean; onChange: () => void }) {
253
+ return (
254
+ <button
255
+ type="button"
256
+ onClick={onChange}
257
+ title={on ? "Disable" : "Enable"}
258
+ aria-pressed={on}
259
+ style={{
260
+ width: 36,
261
+ height: 20,
262
+ borderRadius: 10,
263
+ border: `1px solid ${on ? "var(--green)" : "var(--border-strong)"}`,
264
+ background: on ? "var(--green)" : "var(--surface-muted)",
265
+ cursor: "pointer",
266
+ position: "relative",
267
+ flexShrink: 0,
268
+ padding: 0,
269
+ transition: "background 0.15s ease, border-color 0.15s ease",
270
+ }}
271
+ >
272
+ <span
273
+ style={{
274
+ position: "absolute",
275
+ top: 2,
276
+ left: on ? 17 : 2,
277
+ width: 14,
278
+ height: 14,
279
+ borderRadius: "50%",
280
+ background: on ? "#fff" : "var(--text-3)",
281
+ transition: "left 0.15s ease",
282
+ boxShadow: on ? "0 1px 2px rgba(0,0,0,0.12)" : "none",
283
+ }}
284
+ />
285
+ </button>
286
+ );
287
+ }
288
+
289
+ function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
290
+ return (
291
+ <button
292
+ type="button"
293
+ onClick={onChange}
294
+ title={on ? "Disable" : "Enable"}
295
+ aria-pressed={on}
296
+ style={{
297
+ width: 32,
298
+ height: 18,
299
+ borderRadius: 9,
300
+ border: `1px solid ${on ? "var(--text-1)" : "var(--border-strong)"}`,
301
+ background: on ? "var(--text-1)" : "transparent",
302
+ cursor: "pointer",
303
+ position: "relative",
304
+ flexShrink: 0,
305
+ padding: 0,
306
+ transition: "background 0.15s ease, border-color 0.15s ease",
307
+ }}
308
+ >
309
+ <span
310
+ style={{
311
+ position: "absolute",
312
+ top: 2,
313
+ left: on ? 15 : 2,
314
+ width: 12,
315
+ height: 12,
316
+ borderRadius: "50%",
317
+ background: on ? "var(--surface)" : "var(--text-3)",
318
+ transition: "left 0.15s ease",
319
+ }}
320
+ />
321
+ </button>
322
+ );
323
+ }
324
+
325
+ // ─── Instance card ────────────────────────────────────────────────────────────
326
+
327
+ const OTHER_VALUE = "__other__";
328
+
329
+ function ModelField({
330
+ instance, onUpdate,
331
+ }: {
332
+ instance: LLMInstance;
333
+ onUpdate: (patch: Partial<LLMInstance>) => void;
334
+ }) {
335
+ const { models, loading, error } = useModels(instance.provider, instance.baseUrl, instance.apiKey);
336
+ const [customMode, setCustomMode] = useState(false);
337
+
338
+ const fixedList = FIXED_MODEL_PROVIDERS.has(instance.provider);
339
+ const isPlainText = models.length === 0 || instance.provider === "custom";
340
+ const inList = models.includes(instance.model);
341
+ const showCustomInput = !fixedList && (customMode || (!loading && !inList && instance.model !== ""));
342
+
343
+ useEffect(() => {
344
+ if (!loading && models.length > 0 && !instance.model) {
345
+ onUpdate({ model: models[0] });
346
+ }
347
+ if (!loading && inList) {
348
+ setCustomMode(false);
349
+ }
350
+ }, [loading, models, inList]); // eslint-disable-line react-hooks/exhaustive-deps
351
+
352
+ if (isPlainText) {
353
+ return (
354
+ <input
355
+ value={instance.model}
356
+ onChange={(e) => onUpdate({ model: e.target.value })}
357
+ style={inputStyle}
358
+ placeholder="Model"
359
+ />
360
+ );
361
+ }
362
+
363
+ if (loading) {
364
+ return (
365
+ <div style={{ ...inputStyle, color: "var(--text-3)", display: "flex", alignItems: "center" }}>
366
+ <span style={{ fontSize: "0.75rem" }}>Loading…</span>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ return (
372
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
373
+ <select
374
+ value={showCustomInput ? OTHER_VALUE : instance.model}
375
+ onChange={(e) => {
376
+ if (e.target.value === OTHER_VALUE) {
377
+ setCustomMode(true);
378
+ } else {
379
+ setCustomMode(false);
380
+ onUpdate({ model: e.target.value });
381
+ }
382
+ }}
383
+ style={{ ...inputStyle, cursor: "pointer" }}
384
+ >
385
+ {models.map((m) => (
386
+ <option key={m} value={m}>{m}</option>
387
+ ))}
388
+ {!fixedList && <option value={OTHER_VALUE}>Other…</option>}
389
+ </select>
390
+
391
+ {showCustomInput && (
392
+ <input
393
+ value={instance.model}
394
+ onChange={(e) => onUpdate({ model: e.target.value })}
395
+ style={inputStyle}
396
+ placeholder="Model name"
397
+ autoFocus
398
+ />
399
+ )}
400
+
401
+ {error && (
402
+ <span style={{ fontSize: "0.65rem", color: "var(--text-2)" }}>
403
+ {error} — default list
404
+ </span>
405
+ )}
406
+ </div>
407
+ );
408
+ }
409
+
410
+ function ChevronIcon({ up }: { up: boolean }) {
411
+ return (
412
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
413
+ {up ? <path d="M18 15l-6-6-6 6" /> : <path d="M6 9l6 6 6-6" />}
414
+ </svg>
415
+ );
416
+ }
417
+
418
+ function InstanceCard({
419
+ instance,
420
+ onUpdate,
421
+ onRemove,
422
+ secretNames,
423
+ secrets,
424
+ expanded,
425
+ onToggleExpand,
426
+ }: {
427
+ instance: LLMInstance;
428
+ onUpdate: (patch: Partial<LLMInstance>) => void;
429
+ onRemove: () => void;
430
+ /** Secret variable names (from Settings → Secrets), for API key / group ID picker */
431
+ secretNames: string[];
432
+ secrets: Record<string, string>;
433
+ expanded: boolean;
434
+ onToggleExpand: () => void;
435
+ }) {
436
+ const meta = PRESETS[instance.provider];
437
+ const isMinimax = instance.provider === "minimax";
438
+ const apiRef = instance.apiKeySecretRef?.trim() ?? "";
439
+ const groupRef = instance.groupIdSecretRef?.trim() ?? "";
440
+ const initials = providerInitials(meta.label);
441
+
442
+ const apiKeyField = showApiKey(instance.provider) && (
443
+ <Field
444
+ label={isMinimax ? "Minimax API key" : "API key"}
445
+ labelExtra={apiRef ? <SourceBadge tone="variable">Variable</SourceBadge> : <SourceBadge tone="inline">inline</SourceBadge>}
446
+ >
447
+ <select
448
+ value={apiRef}
449
+ onChange={(e) =>
450
+ onUpdate({
451
+ apiKeySecretRef: e.target.value ? e.target.value : undefined,
452
+ })
453
+ }
454
+ style={{ ...inputStyle, cursor: "pointer", width: "100%", marginBottom: apiRef ? "0.35rem" : 0 }}
455
+ aria-label="API key source"
456
+ >
457
+ <option value="">Inline (paste below)</option>
458
+ {secretNames.map((k) => (
459
+ <option key={k} value={k}>
460
+ Variable: {k}
461
+ </option>
462
+ ))}
463
+ </select>
464
+ {apiRef ? (
465
+ <p
466
+ style={{
467
+ margin: 0,
468
+ fontSize: "0.65rem",
469
+ color: secrets[apiRef]?.trim() ? "var(--text-2)" : "var(--amber)",
470
+ }}
471
+ >
472
+ {secrets[apiRef]?.trim()
473
+ ? `Using saved secret “${apiRef}”.`
474
+ : `Secret “${apiRef}” is empty — add it under Settings → Secrets, or use inline key below.`}
475
+ </p>
476
+ ) : null}
477
+ <input
478
+ type="password"
479
+ value={instance.apiKey ?? ""}
480
+ onChange={(e) => onUpdate({ apiKey: e.target.value })}
481
+ style={{ ...inputStyle, opacity: apiRef && !instance.apiKey?.trim() ? 0.85 : 1 }}
482
+ placeholder={
483
+ apiRef
484
+ ? "Optional override if variable is empty"
485
+ : isMinimax
486
+ ? "Paste key or set MINIMAX_API_KEY in env"
487
+ : "Env or paste key"
488
+ }
489
+ autoComplete="off"
490
+ />
491
+ </Field>
492
+ );
493
+
494
+ const groupIdField = showGroupId(instance.provider) && (
495
+ <Field
496
+ label="Minimax Group ID"
497
+ labelExtra={groupRef ? <SourceBadge tone="variable">Variable</SourceBadge> : <SourceBadge tone="inline">inline</SourceBadge>}
498
+ >
499
+ <select
500
+ value={groupRef}
501
+ onChange={(e) =>
502
+ onUpdate({
503
+ groupIdSecretRef: e.target.value ? e.target.value : undefined,
504
+ })
505
+ }
506
+ style={{ ...inputStyle, cursor: "pointer", width: "100%", marginBottom: groupRef ? "0.35rem" : 0 }}
507
+ aria-label="Group ID source"
508
+ >
509
+ <option value="">Inline (paste below)</option>
510
+ {secretNames.map((k) => (
511
+ <option key={k} value={k}>
512
+ Variable: {k}
513
+ </option>
514
+ ))}
515
+ </select>
516
+ {groupRef ? (
517
+ <p style={{ margin: 0, fontSize: "0.65rem", color: "var(--text-2)" }}>
518
+ {secrets[groupRef]?.trim()
519
+ ? `Using saved secret “${groupRef}”.`
520
+ : `Secret “${groupRef}” is empty — add it under Settings → Secrets.`}
521
+ </p>
522
+ ) : null}
523
+ <input
524
+ value={instance.groupId ?? ""}
525
+ onChange={(e) => onUpdate({ groupId: e.target.value })}
526
+ style={inputStyle}
527
+ placeholder="Paste GroupId or set MINIMAX_GROUP_ID in env"
528
+ autoComplete="off"
529
+ />
530
+ </Field>
531
+ );
532
+
533
+ const modelField = (
534
+ <Field label="Model">
535
+ <ModelField instance={instance} onUpdate={onUpdate} />
536
+ </Field>
537
+ );
538
+
539
+ return (
540
+ <div
541
+ style={{
542
+ background: "var(--surface)",
543
+ border: "1px solid var(--border)",
544
+ borderRadius: "var(--r-xl)",
545
+ overflow: "hidden",
546
+ boxShadow: "var(--shadow-xs)",
547
+ opacity: instance.enabled ? 1 : 0.72,
548
+ transition: "opacity 0.12s ease",
549
+ }}
550
+ >
551
+ <div
552
+ style={{
553
+ display: "flex",
554
+ alignItems: "center",
555
+ gap: "0.65rem",
556
+ padding: "0.75rem 0.85rem",
557
+ minHeight: 52,
558
+ }}
559
+ >
560
+ <div
561
+ style={{
562
+ width: 40,
563
+ height: 40,
564
+ borderRadius: "var(--r-md)",
565
+ background: "var(--surface-muted)",
566
+ border: "1px solid var(--border)",
567
+ display: "flex",
568
+ alignItems: "center",
569
+ justifyContent: "center",
570
+ fontSize: "0.72rem",
571
+ fontWeight: 800,
572
+ color: "var(--text-2)",
573
+ letterSpacing: "-0.02em",
574
+ flexShrink: 0,
575
+ }}
576
+ >
577
+ {initials}
578
+ </div>
579
+ <div style={{ flex: 1, minWidth: 0 }}>
580
+ <div style={{ fontSize: "0.72rem", fontWeight: 600, color: "var(--text-3)", letterSpacing: "0.04em", textTransform: "uppercase" }}>
581
+ {meta.label}
582
+ </div>
583
+ <div
584
+ style={{
585
+ fontSize: "0.84rem",
586
+ fontWeight: 700,
587
+ color: "var(--text-1)",
588
+ marginTop: 2,
589
+ overflow: "hidden",
590
+ textOverflow: "ellipsis",
591
+ whiteSpace: "nowrap",
592
+ fontFamily: "var(--font-mono)",
593
+ }}
594
+ title={instance.model || undefined}
595
+ >
596
+ {instance.model || "No model"}
597
+ </div>
598
+ </div>
599
+ <GreenToggle on={instance.enabled} onChange={() => onUpdate({ enabled: !instance.enabled })} />
600
+ <button
601
+ type="button"
602
+ onClick={onToggleExpand}
603
+ aria-expanded={expanded}
604
+ aria-label={expanded ? "Collapse" : "Expand"}
605
+ style={{
606
+ background: "var(--surface-muted)",
607
+ border: "1px solid var(--border)",
608
+ color: "var(--text-2)",
609
+ cursor: "pointer",
610
+ width: 34,
611
+ height: 34,
612
+ borderRadius: "var(--r-md)",
613
+ display: "flex",
614
+ alignItems: "center",
615
+ justifyContent: "center",
616
+ flexShrink: 0,
617
+ padding: 0,
618
+ fontFamily: "inherit",
619
+ }}
620
+ >
621
+ <ChevronIcon up={expanded} />
622
+ </button>
623
+ </div>
624
+
625
+ {expanded ? (
626
+ <div
627
+ style={{
628
+ borderTop: "1px solid var(--border)",
629
+ padding: "0.85rem 0.85rem 0.95rem",
630
+ display: "flex",
631
+ flexDirection: "column",
632
+ gap: "0.65rem",
633
+ background: "var(--surface-subtle)",
634
+ }}
635
+ >
636
+ {isMinimax && (
637
+ <p
638
+ style={{
639
+ margin: 0,
640
+ fontSize: "0.6875rem",
641
+ lineHeight: 1.45,
642
+ color: "var(--text-2)",
643
+ }}
644
+ >
645
+ Minimax needs an API key and a Group ID. Enter them here, or define{" "}
646
+ <span style={{ fontFamily: "var(--font-mono)", fontSize: "0.65rem" }}>MINIMAX_API_KEY</span> and{" "}
647
+ <span style={{ fontFamily: "var(--font-mono)", fontSize: "0.65rem" }}>MINIMAX_GROUP_ID</span> for the
648
+ server.
649
+ </p>
650
+ )}
651
+
652
+ {isMinimax ? (
653
+ <>
654
+ {apiKeyField}
655
+ {groupIdField}
656
+ {modelField}
657
+ </>
658
+ ) : (
659
+ <>
660
+ {modelField}
661
+ {apiKeyField}
662
+ </>
663
+ )}
664
+
665
+ {showBaseUrl(instance.provider) && (
666
+ <Field label="Base URL">
667
+ <input
668
+ value={instance.baseUrl ?? ""}
669
+ onChange={(e) => onUpdate({ baseUrl: e.target.value })}
670
+ style={inputStyle}
671
+ placeholder={
672
+ instance.provider === "ollama"
673
+ ? "http://localhost:11434"
674
+ : instance.provider === "custom"
675
+ ? "https://…/v1"
676
+ : PRESET_BASE_URLS[instance.provider as OAIPreset] ?? ""
677
+ }
678
+ />
679
+ </Field>
680
+ )}
681
+
682
+ <div
683
+ style={{
684
+ display: "grid",
685
+ gridTemplateColumns: showMaxTokens(instance.provider) ? "1fr 1fr" : "1fr",
686
+ gap: "0.55rem",
687
+ }}
688
+ >
689
+ {showMaxTokens(instance.provider) && (
690
+ <Field label="Max tokens">
691
+ <input
692
+ type="number"
693
+ value={instance.maxTokens ?? 2048}
694
+ onChange={(e) => onUpdate({ maxTokens: parseInt(e.target.value, 10) || 2048 })}
695
+ style={{ ...inputStyle, width: "100%" }}
696
+ min={1}
697
+ max={128000}
698
+ step={256}
699
+ />
700
+ </Field>
701
+ )}
702
+ <Field label={`Temperature · ${(instance.temperature ?? 0.7).toFixed(2)}`}>
703
+ <input
704
+ type="range"
705
+ value={instance.temperature ?? 0.7}
706
+ onChange={(e) => onUpdate({ temperature: parseFloat(e.target.value) })}
707
+ min={0}
708
+ max={1}
709
+ step={0.05}
710
+ style={{
711
+ width: "100%",
712
+ accentColor: "var(--text-1)",
713
+ cursor: "pointer",
714
+ }}
715
+ />
716
+ </Field>
717
+ </div>
718
+ </div>
719
+ ) : null}
720
+ </div>
721
+ );
722
+ }
723
+
724
+ // ─── Models tab (embedded in Settings panel) ──────────────────────────────────
725
+
726
+ export interface ModelsSettingsSectionProps {
727
+ instances: LLMInstance[];
728
+ onUpdate: (instances: LLMInstance[]) => void;
729
+ /** Keys defined under Settings → Secrets */
730
+ secretNames: string[];
731
+ secrets: Record<string, string>;
732
+ }
733
+
734
+ const ADD_PLACEHOLDER = "";
735
+
736
+ export function ModelsSettingsSection({
737
+ instances,
738
+ onUpdate,
739
+ secretNames,
740
+ secrets,
741
+ }: ModelsSettingsSectionProps) {
742
+ const [addProviderValue, setAddProviderValue] = useState(ADD_PLACEHOLDER);
743
+ const [expandedCardId, setExpandedCardId] = useState<string | null>(null);
744
+
745
+ const patch = (id: string, p: Partial<LLMInstance>) =>
746
+ onUpdate(instances.map((i) => (i.id === id ? { ...i, ...p } : i)));
747
+ const remove = (id: string) => onUpdate(instances.filter((i) => i.id !== id));
748
+ const add = (provider: AnyProvider) => onUpdate([...instances, makeInstance(provider)]);
749
+
750
+ const active = instances.filter((i) => i.enabled).length;
751
+
752
+ return (
753
+ <div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
754
+ <div style={{ padding: "0 0 0.5rem" }}>
755
+ <p style={{ margin: "0 0 0.35rem", fontSize: "0.6875rem", color: "var(--text-3)" }}>
756
+ {active} active · {instances.length} total
757
+ </p>
758
+ <Field label="Add provider">
759
+ <select
760
+ value={addProviderValue}
761
+ onChange={(e) => {
762
+ const v = e.target.value as AnyProvider;
763
+ if (!v) return;
764
+ add(v);
765
+ setAddProviderValue(ADD_PLACEHOLDER);
766
+ }}
767
+ style={{ ...inputStyle, cursor: "pointer", width: "100%" }}
768
+ aria-label="Add provider"
769
+ >
770
+ <option value={ADD_PLACEHOLDER}>Choose a provider…</option>
771
+ {PRESET_ORDER.map((p) => {
772
+ const m = PRESETS[p];
773
+ return (
774
+ <option key={p} value={p}>
775
+ {m.label} — {m.tag}
776
+ </option>
777
+ );
778
+ })}
779
+ </select>
780
+ </Field>
781
+ </div>
782
+
783
+ <div style={{ height: 1, background: "var(--border)", margin: "0.35rem 0 0.65rem" }} />
784
+
785
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1, overflowY: "auto", paddingBottom: "0.5rem" }}>
786
+ {instances.length === 0 ? (
787
+ <p style={{ textAlign: "center", padding: "2rem 0.5rem", color: "var(--text-3)", fontSize: "0.8125rem", margin: 0 }}>
788
+ No models. Choose a provider from the dropdown above.
789
+ </p>
790
+ ) : (
791
+ instances.map((instance) => (
792
+ <InstanceCard
793
+ key={instance.id}
794
+ instance={instance}
795
+ onUpdate={(p) => patch(instance.id, p)}
796
+ onRemove={() => remove(instance.id)}
797
+ secretNames={secretNames}
798
+ secrets={secrets}
799
+ expanded={expandedCardId === instance.id}
800
+ onToggleExpand={() =>
801
+ setExpandedCardId((cur) => (cur === instance.id ? null : instance.id))
802
+ }
803
+ />
804
+ ))
805
+ )}
806
+ </div>
807
+ </div>
808
+ );
809
+ }