1import React, { useState, useEffect, useCallback } from "react";
2import Modal from "./Modal";
3import {
4 customModelsApi,
5 CustomModel,
6 CreateCustomModelRequest,
7 TestCustomModelRequest,
8} from "../services/api";
9
10interface ModelsModalProps {
11 isOpen: boolean;
12 onClose: () => void;
13 onModelsChanged?: () => void;
14}
15
16type ProviderType = "anthropic" | "openai" | "openai-responses" | "gemini";
17
18const DEFAULT_ENDPOINTS: Record<ProviderType, string> = {
19 anthropic: "https://api.anthropic.com/v1/messages",
20 openai: "https://api.openai.com/v1",
21 "openai-responses": "https://api.openai.com/v1",
22 gemini: "https://generativelanguage.googleapis.com/v1beta",
23};
24
25const PROVIDER_LABELS: Record<ProviderType, string> = {
26 anthropic: "Anthropic",
27 openai: "OpenAI (Chat API)",
28 "openai-responses": "OpenAI (Responses API)",
29 gemini: "Google Gemini",
30};
31
32const DEFAULT_MODELS: Record<ProviderType, { name: string; model_name: string }[]> = {
33 anthropic: [
34 { name: "Claude Sonnet 4.5", model_name: "claude-sonnet-4-5" },
35 { name: "Claude Opus 4.5", model_name: "claude-opus-4-5" },
36 { name: "Claude Haiku 4.5", model_name: "claude-haiku-4-5" },
37 ],
38 openai: [{ name: "GPT-5.2", model_name: "gpt-5.2" }],
39 "openai-responses": [{ name: "GPT-5.2 Codex", model_name: "gpt-5.2-codex" }],
40 gemini: [
41 { name: "Gemini 3 Pro", model_name: "gemini-3-pro-preview" },
42 { name: "Gemini 3 Flash", model_name: "gemini-3-flash-preview" },
43 ],
44};
45
46// Built-in model info from init data
47interface BuiltInModel {
48 id: string;
49 display_name?: string;
50 source?: string;
51 ready: boolean;
52}
53
54interface FormData {
55 display_name: string;
56 provider_type: ProviderType;
57 endpoint: string;
58 endpoint_custom: boolean;
59 api_key: string;
60 model_name: string;
61 max_tokens: number;
62 tags: string; // Comma-separated tags
63}
64
65const emptyForm: FormData = {
66 display_name: "",
67 provider_type: "anthropic",
68 endpoint: DEFAULT_ENDPOINTS.anthropic,
69 endpoint_custom: false,
70 api_key: "",
71 model_name: "",
72 max_tokens: 200000,
73 tags: "",
74};
75
76function ModelsModal({ isOpen, onClose, onModelsChanged }: ModelsModalProps) {
77 const [models, setModels] = useState<CustomModel[]>([]);
78 const [loading, setLoading] = useState(true);
79 const [error, setError] = useState<string | null>(null);
80 const [builtInModels, setBuiltInModels] = useState<BuiltInModel[]>([]);
81
82 // Form state
83 const [showForm, setShowForm] = useState(false);
84 const [editingModelId, setEditingModelId] = useState<string | null>(null);
85 const [form, setForm] = useState<FormData>(emptyForm);
86
87 // Test state
88 const [testing, setTesting] = useState(false);
89 const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
90
91 // Tooltip state
92 const [showTagsTooltip, setShowTagsTooltip] = useState(false);
93
94 const loadModels = useCallback(async () => {
95 try {
96 setLoading(true);
97 setError(null);
98 const result = await customModelsApi.getCustomModels();
99 setModels(result);
100 } catch (err) {
101 setError(err instanceof Error ? err.message : "Failed to load models");
102 } finally {
103 setLoading(false);
104 }
105 }, []);
106
107 useEffect(() => {
108 if (isOpen) {
109 loadModels();
110 // Get built-in models from init data (those with non-custom source)
111 const initData = window.__SHELLEY_INIT__;
112 if (initData?.models) {
113 const builtIn = initData.models.filter(
114 (m: BuiltInModel) => m.source && m.source !== "custom",
115 );
116 setBuiltInModels(builtIn);
117 }
118 }
119 }, [isOpen, loadModels]);
120
121 const handleProviderChange = (provider: ProviderType) => {
122 setForm((prev) => ({
123 ...prev,
124 provider_type: provider,
125 endpoint: prev.endpoint_custom ? prev.endpoint : DEFAULT_ENDPOINTS[provider],
126 }));
127 };
128
129 const handleEndpointModeChange = (custom: boolean) => {
130 setForm((prev) => ({
131 ...prev,
132 endpoint_custom: custom,
133 endpoint: custom ? prev.endpoint : DEFAULT_ENDPOINTS[prev.provider_type],
134 }));
135 };
136
137 const handleSelectPresetModel = (preset: { name: string; model_name: string }) => {
138 setForm((prev) => ({
139 ...prev,
140 display_name: preset.name,
141 model_name: preset.model_name,
142 }));
143 };
144
145 const handleTest = async () => {
146 // Need model_name always, and either api_key or editing an existing model
147 if (!form.model_name) {
148 setTestResult({ success: false, message: "Model name is required" });
149 return;
150 }
151 if (!form.api_key && !editingModelId) {
152 setTestResult({ success: false, message: "API key is required" });
153 return;
154 }
155
156 setTesting(true);
157 setTestResult(null);
158
159 try {
160 const request: TestCustomModelRequest = {
161 model_id: editingModelId || undefined, // Pass model_id to use stored key
162 provider_type: form.provider_type,
163 endpoint: form.endpoint,
164 api_key: form.api_key,
165 model_name: form.model_name,
166 };
167 const result = await customModelsApi.testCustomModel(request);
168 setTestResult(result);
169 } catch (err) {
170 setTestResult({
171 success: false,
172 message: err instanceof Error ? err.message : "Test failed",
173 });
174 } finally {
175 setTesting(false);
176 }
177 };
178
179 const handleSave = async () => {
180 if (!form.display_name || !form.api_key || !form.model_name) {
181 setError("Display name, API key, and model name are required");
182 return;
183 }
184
185 try {
186 setError(null);
187 const request: CreateCustomModelRequest = {
188 display_name: form.display_name,
189 provider_type: form.provider_type,
190 endpoint: form.endpoint,
191 api_key: form.api_key,
192 model_name: form.model_name,
193 max_tokens: form.max_tokens,
194 tags: form.tags,
195 };
196
197 if (editingModelId) {
198 await customModelsApi.updateCustomModel(editingModelId, request);
199 } else {
200 await customModelsApi.createCustomModel(request);
201 }
202
203 setShowForm(false);
204 setEditingModelId(null);
205 setForm(emptyForm);
206 setTestResult(null);
207 await loadModels();
208 onModelsChanged?.();
209 } catch (err) {
210 setError(err instanceof Error ? err.message : "Failed to save model");
211 }
212 };
213
214 const handleEdit = (model: CustomModel) => {
215 setEditingModelId(model.model_id);
216 setForm({
217 display_name: model.display_name,
218 provider_type: model.provider_type,
219 endpoint: model.endpoint,
220 endpoint_custom: model.endpoint !== DEFAULT_ENDPOINTS[model.provider_type],
221 api_key: model.api_key,
222 model_name: model.model_name,
223 max_tokens: model.max_tokens,
224 tags: model.tags,
225 });
226 setShowForm(true);
227 setTestResult(null);
228 };
229
230 const handleDuplicate = async (model: CustomModel) => {
231 try {
232 setError(null);
233 await customModelsApi.duplicateCustomModel(model.model_id);
234 await loadModels();
235 onModelsChanged?.();
236 } catch (err) {
237 setError(err instanceof Error ? err.message : "Failed to duplicate model");
238 }
239 };
240
241 const handleDelete = async (modelId: string) => {
242 try {
243 setError(null);
244 await customModelsApi.deleteCustomModel(modelId);
245 await loadModels();
246 onModelsChanged?.();
247 } catch (err) {
248 setError(err instanceof Error ? err.message : "Failed to delete model");
249 }
250 };
251
252 const handleCancel = () => {
253 setShowForm(false);
254 setEditingModelId(null);
255 setForm(emptyForm);
256 setTestResult(null);
257 };
258
259 const handleAddNew = () => {
260 setEditingModelId(null);
261 setForm(emptyForm);
262 setShowForm(true);
263 setTestResult(null);
264 };
265
266 const headerRight = !showForm ? (
267 <button className="btn-primary btn-sm" onClick={handleAddNew}>
268 + Add Model
269 </button>
270 ) : null;
271
272 return (
273 <Modal
274 isOpen={isOpen}
275 onClose={onClose}
276 title="Manage Models"
277 titleRight={headerRight}
278 className="modal-wide"
279 >
280 <div className="models-modal">
281 {error && (
282 <div className="models-error">
283 {error}
284 <button onClick={() => setError(null)} className="models-error-dismiss">
285 ×
286 </button>
287 </div>
288 )}
289
290 {loading ? (
291 <div className="models-loading">
292 <div className="spinner"></div>
293 <span>Loading models...</span>
294 </div>
295 ) : showForm ? (
296 // Add/Edit form
297 <div className="model-form">
298 <h3>{editingModelId ? "Edit Model" : "Add Model"}</h3>
299
300 {/* Provider Selection */}
301 <div className="form-group">
302 <label>Provider / API Format</label>
303 <div className="provider-buttons">
304 {(["anthropic", "openai", "openai-responses", "gemini"] as ProviderType[]).map(
305 (p) => (
306 <button
307 key={p}
308 type="button"
309 className={`provider-btn ${form.provider_type === p ? "selected" : ""}`}
310 onClick={() => handleProviderChange(p)}
311 >
312 {PROVIDER_LABELS[p]}
313 </button>
314 ),
315 )}
316 </div>
317 </div>
318
319 {/* Endpoint Selection */}
320 <div className="form-group">
321 <label>Endpoint</label>
322 <div className="endpoint-toggle">
323 <button
324 type="button"
325 className={`toggle-btn ${!form.endpoint_custom ? "selected" : ""}`}
326 onClick={() => handleEndpointModeChange(false)}
327 >
328 Default
329 </button>
330 <button
331 type="button"
332 className={`toggle-btn ${form.endpoint_custom ? "selected" : ""}`}
333 onClick={() => handleEndpointModeChange(true)}
334 >
335 Custom
336 </button>
337 </div>
338 {form.endpoint_custom ? (
339 <input
340 type="text"
341 value={form.endpoint}
342 onChange={(e) => setForm((prev) => ({ ...prev, endpoint: e.target.value }))}
343 placeholder="https://..."
344 className="form-input"
345 />
346 ) : (
347 <div className="endpoint-display">{form.endpoint}</div>
348 )}
349 </div>
350
351 {/* Model Name with Presets */}
352 <div className="form-group">
353 <label>Model</label>
354 <div className="model-presets">
355 {DEFAULT_MODELS[form.provider_type].map((preset) => (
356 <button
357 key={preset.model_name}
358 type="button"
359 className={`preset-btn ${form.model_name === preset.model_name ? "selected" : ""}`}
360 onClick={() => handleSelectPresetModel(preset)}
361 >
362 {preset.name}
363 </button>
364 ))}
365 </div>
366 <input
367 type="text"
368 value={form.model_name}
369 onChange={(e) => setForm((prev) => ({ ...prev, model_name: e.target.value }))}
370 placeholder="Model name (e.g., claude-sonnet-4-5)"
371 className="form-input"
372 />
373 </div>
374
375 {/* Display Name */}
376 <div className="form-group">
377 <label>Display Name</label>
378 <input
379 type="text"
380 value={form.display_name}
381 onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
382 placeholder="Name shown in model selector"
383 className="form-input"
384 />
385 </div>
386
387 {/* API Key */}
388 <div className="form-group">
389 <label>API Key</label>
390 <input
391 type="text"
392 value={form.api_key}
393 onChange={(e) => setForm((prev) => ({ ...prev, api_key: e.target.value }))}
394 placeholder="Enter API key"
395 className="form-input"
396 autoComplete="off"
397 />
398 </div>
399
400 {/* Max Tokens */}
401 <div className="form-group">
402 <label>Max Context Tokens</label>
403 <input
404 type="number"
405 value={form.max_tokens}
406 onChange={(e) =>
407 setForm((prev) => ({ ...prev, max_tokens: parseInt(e.target.value) || 200000 }))
408 }
409 className="form-input"
410 />
411 </div>
412
413 {/* Tags */}
414 <div className="form-group">
415 <label>
416 Tags
417 <span
418 className="info-icon-wrapper"
419 onClick={(e) => {
420 e.preventDefault();
421 e.stopPropagation();
422 setShowTagsTooltip(!showTagsTooltip);
423 }}
424 >
425 <span className="info-icon">
426 <svg
427 fill="none"
428 stroke="currentColor"
429 viewBox="0 0 24 24"
430 width="14"
431 height="14"
432 >
433 <path
434 strokeLinecap="round"
435 strokeLinejoin="round"
436 strokeWidth={2}
437 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
438 />
439 </svg>
440 </span>
441 {showTagsTooltip && (
442 <span className="info-tooltip">
443 Comma-separated tags for this model. Use "slug" to mark this model for
444 generating conversation titles. If no model has the "slug" tag, the
445 conversation's model will be used.
446 </span>
447 )}
448 </span>
449 </label>
450 <input
451 type="text"
452 value={form.tags}
453 onChange={(e) => setForm((prev) => ({ ...prev, tags: e.target.value }))}
454 placeholder="comma-separated, e.g., slug, cheap"
455 className="form-input"
456 />
457 </div>
458
459 {/* Test Result */}
460 {testResult && (
461 <div className={`test-result ${testResult.success ? "success" : "error"}`}>
462 {testResult.success ? "✓" : "✗"} {testResult.message}
463 </div>
464 )}
465
466 {/* Form Actions */}
467 <div className="form-actions">
468 <button type="button" className="btn-secondary" onClick={handleCancel}>
469 Cancel
470 </button>
471 <button
472 type="button"
473 className="btn-secondary"
474 onClick={handleTest}
475 disabled={testing || (!form.api_key && !editingModelId) || !form.model_name}
476 title={
477 !form.model_name
478 ? "Enter model name to test"
479 : !form.api_key && !editingModelId
480 ? "Enter API key to test"
481 : ""
482 }
483 >
484 {testing ? "Testing..." : "Test"}
485 </button>
486 <button
487 type="button"
488 className="btn-primary"
489 onClick={handleSave}
490 disabled={!form.display_name || !form.api_key || !form.model_name}
491 >
492 {editingModelId ? "Save" : "Add Model"}
493 </button>
494 </div>
495 </div>
496 ) : (
497 // Model List
498 <>
499 <div className="models-list">
500 {/* Built-in models (from env vars or gateway) - read only */}
501 {builtInModels
502 .filter((m) => m.id !== "predictable")
503 .map((model) => (
504 <div key={model.id} className="model-card model-card-builtin">
505 <div className="model-header">
506 <div className="model-info">
507 <span className="model-name">{model.display_name || model.id}</span>
508 <span className="model-source">{model.source}</span>
509 </div>
510 </div>
511 <div className="model-details">
512 <span className="model-api-name">{model.id}</span>
513 </div>
514 </div>
515 ))}
516
517 {/* Custom models - editable */}
518 {models.map((model) => (
519 <div key={model.model_id} className="model-card">
520 <div className="model-header">
521 <div className="model-info">
522 <span className="model-name">{model.display_name}</span>
523 <span className="model-provider">{PROVIDER_LABELS[model.provider_type]}</span>
524 {model.tags && (
525 <span className="model-badge" title={model.tags}>
526 {model.tags.split(",")[0]}
527 </span>
528 )}
529 </div>
530 <div className="model-actions">
531 <button
532 className="btn-icon"
533 onClick={() => handleDuplicate(model)}
534 title="Duplicate"
535 >
536 <svg
537 fill="none"
538 stroke="currentColor"
539 viewBox="0 0 24 24"
540 width="16"
541 height="16"
542 >
543 <path
544 strokeLinecap="round"
545 strokeLinejoin="round"
546 strokeWidth={2}
547 d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
548 />
549 </svg>
550 </button>
551 <button className="btn-icon" onClick={() => handleEdit(model)} title="Edit">
552 <svg
553 fill="none"
554 stroke="currentColor"
555 viewBox="0 0 24 24"
556 width="16"
557 height="16"
558 >
559 <path
560 strokeLinecap="round"
561 strokeLinejoin="round"
562 strokeWidth={2}
563 d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
564 />
565 </svg>
566 </button>
567 <button
568 className="btn-icon btn-danger"
569 onClick={() => handleDelete(model.model_id)}
570 title="Delete"
571 >
572 <svg
573 fill="none"
574 stroke="currentColor"
575 viewBox="0 0 24 24"
576 width="16"
577 height="16"
578 >
579 <path
580 strokeLinecap="round"
581 strokeLinejoin="round"
582 strokeWidth={2}
583 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
584 />
585 </svg>
586 </button>
587 </div>
588 </div>
589 <div className="model-details">
590 <span className="model-api-name">{model.model_name}</span>
591 <span className="model-endpoint">{model.endpoint}</span>
592 </div>
593 </div>
594 ))}
595
596 {/* Empty state when no models at all */}
597 {builtInModels.length === 0 && models.length === 0 && (
598 <div className="models-empty">
599 <p>No models configured.</p>
600 <p className="models-empty-hint">
601 Set environment variables like ANTHROPIC_API_KEY, or use the -gateway flag, or
602 add a custom model below.
603 </p>
604 </div>
605 )}
606 </div>
607 </>
608 )}
609 </div>
610 </Modal>
611 );
612}
613
614export default ModelsModal;