Spaces:
Running
Running
File size: 10,517 Bytes
e1d8498 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | import { useState, useEffect } from "react";
import { X, ExternalLink, Info, Eye, EyeOff } from "lucide-react";
import { api } from "../api";
const MODEL_TYPES = [
{ value: "chat", label: "Chat / Instruct" },
{ value: "completion", label: "Text Completion" },
{ value: "embedding", label: "Embedding" },
{ value: "image", label: "Image Generation" },
{ value: "audio", label: "Audio / Speech" },
];
export default function ModelForm({ initial, onSave, onClose }) {
const isEdit = !!initial;
const [form, setForm] = useState({
displayName: initial?.displayName || "",
provider: initial?.provider || "",
litellmModel:initial?.litellmModel|| "",
apiBase: initial?.apiBase || "",
apiKey: "",
description: initial?.description || "",
modelType: initial?.modelType || "chat",
tags: initial?.tags?.join(", ") || "",
});
const [providers, setProviders] = useState([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [showKey, setShowKey] = useState(false);
const [selectedProvider, setSelectedProvider] = useState(null);
useEffect(() => {
api.getProviders().then(r => setProviders(r.data)).catch(() => {});
}, []);
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
const handleProviderSelect = (e) => {
const prov = providers.find(p => p.id === e.target.value);
setSelectedProvider(prov || null);
if (prov) {
setForm(f => ({
...f,
provider: prov.id,
apiBase: prov.defaultApiBase || "",
litellmModel: prov.exampleModels?.[0] || "",
modelType: prov.modelTypes?.[0] || "chat",
}));
}
};
const handleSubmit = async () => {
if (!form.displayName.trim() || !form.provider.trim() || !form.litellmModel.trim()) {
setError("Display name, provider, and LiteLLM model are required.");
return;
}
setSaving(true);
setError(null);
try {
await onSave({
...form,
tags: form.tags.split(",").map(t => t.trim()).filter(Boolean),
apiKey: form.apiKey || undefined,
});
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-xl glass rounded-xl border border-white/10
shadow-2xl shadow-black/50 animate-slide-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/5">
<div>
<h2 className="font-display font-semibold text-text-primary">
{isEdit ? "Edit Model" : "Register New Model"}
</h2>
<p className="text-text-muted text-xs mt-0.5">
{isEdit ? "Update model configuration." : "Add any AI model to the gateway."}
</p>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Provider quick-select */}
{!isEdit && providers.length > 0 && (
<div>
<label className="form-label">Quick-select provider</label>
<select className="select" onChange={handleProviderSelect} defaultValue="">
<option value="">— choose a provider to pre-fill —</option>
{providers.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{selectedProvider && (
<a
href={selectedProvider.docs}
target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-accent-cyan text-xs mt-1.5 hover:underline"
>
<ExternalLink size={10} />
LiteLLM docs for {selectedProvider.name}
</a>
)}
</div>
)}
{/* Display name */}
<div>
<label className="form-label">Display Name <Required /></label>
<input
className="input"
placeholder="e.g. My Claude Proxy"
value={form.displayName}
onChange={set("displayName")}
/>
</div>
{/* Provider */}
<div>
<label className="form-label">Provider ID <Required /></label>
<input
className="input"
placeholder="e.g. anthropic, openai, ollama"
value={form.provider}
onChange={set("provider")}
/>
<p className="text-text-muted text-xs mt-1">
Used to build the OpenAI model alias: <span className="font-mono">provider/model</span>
</p>
</div>
{/* LiteLLM model name */}
<div>
<label className="form-label">
LiteLLM Model Name <Required />
<a
href="https://docs.litellm.ai/docs/providers"
target="_blank" rel="noreferrer"
className="ml-2 text-accent-cyan text-xs inline-flex items-center gap-0.5 hover:underline"
>
<ExternalLink size={9} /> LiteLLM docs
</a>
</label>
<input
className="input input-mono"
placeholder="e.g. anthropic/claude-3-5-sonnet-20241022"
value={form.litellmModel}
onChange={set("litellmModel")}
/>
{selectedProvider?.exampleModels?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{selectedProvider.exampleModels.map(m => (
<button
key={m}
type="button"
onClick={() => setForm(f => ({ ...f, litellmModel: m }))}
className="badge badge-gray cursor-pointer hover:badge-green font-mono text-[0.65rem]"
>
{m}
</button>
))}
</div>
)}
</div>
{/* API Base */}
<div>
<label className="form-label">API Base URL <span className="text-text-muted">(optional)</span></label>
<input
className="input input-mono"
placeholder="e.g. https://api.anthropic.com or http://localhost:11434"
value={form.apiBase}
onChange={set("apiBase")}
/>
<p className="text-text-muted text-xs mt-1">
Leave blank to use LiteLLM's default for this provider.
</p>
</div>
{/* API Key */}
<div>
<label className="form-label">
API Key <span className="text-text-muted">(optional)</span>
</label>
<div className="relative">
<input
className="input input-mono pr-9"
placeholder={isEdit ? "Leave blank to keep existing" : "sk-… (leave empty for keyless providers)"}
type={showKey ? "text" : "password"}
value={form.apiKey}
onChange={set("apiKey")}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowKey(s => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<p className="text-text-muted text-xs mt-1 flex items-center gap-1">
<Info size={10} />
Keys are stored server-side and masked in the UI.
For Ollama and local models, no key is needed.
</p>
</div>
{/* Model type */}
<div>
<label className="form-label">Model Type</label>
<select className="select" value={form.modelType} onChange={set("modelType")}>
{MODEL_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Description */}
<div>
<label className="form-label">Description <span className="text-text-muted">(optional)</span></label>
<textarea
className="input resize-none"
rows={2}
placeholder="Brief description of this model endpoint…"
value={form.description}
onChange={set("description")}
/>
</div>
{/* Tags */}
<div>
<label className="form-label">Tags <span className="text-text-muted">(comma-separated)</span></label>
<input
className="input"
placeholder="e.g. production, fast, vision"
value={form.tags}
onChange={set("tags")}
/>
</div>
{error && (
<div className="p-3 rounded-lg bg-accent-red/10 border border-accent-red/20 text-accent-red text-sm">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-white/5 bg-surface-1/30">
<button onClick={onClose} className="btn-secondary">Cancel</button>
<button onClick={handleSubmit} disabled={saving} className="btn-primary">
{saving ? "Saving…" : isEdit ? "Save Changes" : "Register Model"}
</button>
</div>
</div>
</div>
);
}
function Required() {
return <span className="text-accent-red text-xs ml-0.5">*</span>;
}
// BUG FIX: Removed module-level document.createElement("style") — it ran on every
// module evaluation and injected duplicate <style> tags on HMR reloads.
// The .form-label class is now defined in index.css @layer components.
|