ModelsModal.tsx

  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;