waypoi 0.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 (260) hide show
  1. package/.github/instructions/ui.instructions.md +42 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +71 -0
  4. package/.github/workflows/release.yml +48 -0
  5. package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
  6. package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
  7. package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
  8. package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
  10. package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
  11. package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
  12. package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
  13. package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
  14. package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
  15. package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
  16. package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
  17. package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
  18. package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
  19. package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
  20. package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
  21. package/AGENTS.md +48 -0
  22. package/CHANGELOG.md +131 -0
  23. package/README.md +552 -0
  24. package/assets/agent-mode.png +0 -0
  25. package/assets/categorize.png +0 -0
  26. package/assets/dashboard.png +0 -0
  27. package/assets/endpoint-proxy.png +0 -0
  28. package/assets/icon.png +0 -0
  29. package/assets/mcp-generate-image.png +0 -0
  30. package/assets/mcp-understand-image.png +0 -0
  31. package/assets/peek-token-flow.png +0 -0
  32. package/assets/playground.png +0 -0
  33. package/assets/sankey.png +0 -0
  34. package/cli/index.ts +2805 -0
  35. package/cli/legacyRewrite.ts +108 -0
  36. package/cli/modelRef.ts +24 -0
  37. package/dist/cli/index.js +2536 -0
  38. package/dist/cli/legacyRewrite.js +92 -0
  39. package/dist/cli/modelRef.js +20 -0
  40. package/dist/src/benchmark/artifacts.js +131 -0
  41. package/dist/src/benchmark/capabilityClassifier.js +81 -0
  42. package/dist/src/benchmark/capabilityStore.js +144 -0
  43. package/dist/src/benchmark/config.js +238 -0
  44. package/dist/src/benchmark/gates.js +118 -0
  45. package/dist/src/benchmark/jobs.js +252 -0
  46. package/dist/src/benchmark/runner.js +1847 -0
  47. package/dist/src/benchmark/schema.js +353 -0
  48. package/dist/src/benchmark/suites.js +314 -0
  49. package/dist/src/benchmark/tinyQaDataset.js +422 -0
  50. package/dist/src/benchmark/types.js +25 -0
  51. package/dist/src/config.js +47 -0
  52. package/dist/src/index.js +178 -0
  53. package/dist/src/mcp/client.js +215 -0
  54. package/dist/src/mcp/discovery.js +226 -0
  55. package/dist/src/mcp/policy.js +65 -0
  56. package/dist/src/mcp/registry.js +129 -0
  57. package/dist/src/mcp/service.js +460 -0
  58. package/dist/src/middleware/auth.js +179 -0
  59. package/dist/src/middleware/requestCapture.js +192 -0
  60. package/dist/src/middleware/requestStats.js +118 -0
  61. package/dist/src/pools/builder.js +132 -0
  62. package/dist/src/pools/repository.js +69 -0
  63. package/dist/src/pools/scheduler.js +360 -0
  64. package/dist/src/pools/types.js +2 -0
  65. package/dist/src/protocols/adapters/dashscope.js +267 -0
  66. package/dist/src/protocols/adapters/inferenceV2.js +346 -0
  67. package/dist/src/protocols/adapters/openai.js +27 -0
  68. package/dist/src/protocols/registry.js +99 -0
  69. package/dist/src/protocols/types.js +2 -0
  70. package/dist/src/providers/health.js +153 -0
  71. package/dist/src/providers/importer.js +289 -0
  72. package/dist/src/providers/modelRegistry.js +313 -0
  73. package/dist/src/providers/repository.js +361 -0
  74. package/dist/src/providers/types.js +2 -0
  75. package/dist/src/routes/admin.js +531 -0
  76. package/dist/src/routes/audio.js +295 -0
  77. package/dist/src/routes/chat.js +240 -0
  78. package/dist/src/routes/embeddings.js +157 -0
  79. package/dist/src/routes/images.js +288 -0
  80. package/dist/src/routes/mcp.js +256 -0
  81. package/dist/src/routes/mcpService.js +100 -0
  82. package/dist/src/routes/models.js +48 -0
  83. package/dist/src/routes/responses.js +711 -0
  84. package/dist/src/routes/sessions.js +450 -0
  85. package/dist/src/routes/stats.js +270 -0
  86. package/dist/src/routes/ui.js +97 -0
  87. package/dist/src/routes/videos.js +107 -0
  88. package/dist/src/routing/router.js +338 -0
  89. package/dist/src/services/imageGeneration.js +280 -0
  90. package/dist/src/services/imageUnderstanding.js +352 -0
  91. package/dist/src/services/videoGeneration.js +79 -0
  92. package/dist/src/storage/captureRepository.js +1591 -0
  93. package/dist/src/storage/files.js +157 -0
  94. package/dist/src/storage/imageCache.js +346 -0
  95. package/dist/src/storage/repositories.js +388 -0
  96. package/dist/src/storage/sessionRepository.js +370 -0
  97. package/dist/src/storage/statsRepository.js +204 -0
  98. package/dist/src/transport/httpClient.js +126 -0
  99. package/dist/src/types.js +2 -0
  100. package/dist/src/utils/messageMedia.js +285 -0
  101. package/dist/src/utils/modelCapabilities.js +108 -0
  102. package/dist/src/utils/modelDiscovery.js +170 -0
  103. package/dist/src/version.js +5 -0
  104. package/dist/src/workers/captureRetention.js +25 -0
  105. package/dist/src/workers/configWatcher.js +91 -0
  106. package/dist/src/workers/healthChecker.js +21 -0
  107. package/dist/src/workers/statsRotation.js +41 -0
  108. package/docs/LLM/output_schema.md +312 -0
  109. package/docs/benchmark.md +208 -0
  110. package/docs/mcp-guidelines.md +125 -0
  111. package/docs/mcp-service.md +178 -0
  112. package/docs/opencode.md +86 -0
  113. package/docs/providers.md +79 -0
  114. package/examples/benchmark.config.yaml +28 -0
  115. package/examples/providers/alibaba-dashscope.yaml +88 -0
  116. package/examples/providers/alibaba-llm.yaml +64 -0
  117. package/examples/providers/alibaba-registry.yaml +7 -0
  118. package/examples/providers/inference-v2-ray.yaml +29 -0
  119. package/examples/scenarios/assets/omni-call-sample.wav +0 -0
  120. package/examples/scenarios/custom.jsonl +5 -0
  121. package/examples/scenarios/custom.yaml +40 -0
  122. package/model-form-v2.png +0 -0
  123. package/package.json +66 -0
  124. package/provider-form-v2.png +0 -0
  125. package/provider-form.png +0 -0
  126. package/scripts/manual-test.sh +11 -0
  127. package/scripts/version-from-git.js +23 -0
  128. package/src/benchmark/artifacts.ts +149 -0
  129. package/src/benchmark/capabilityClassifier.ts +99 -0
  130. package/src/benchmark/capabilityStore.ts +174 -0
  131. package/src/benchmark/config.ts +337 -0
  132. package/src/benchmark/gates.ts +164 -0
  133. package/src/benchmark/jobs.ts +312 -0
  134. package/src/benchmark/runner.ts +2519 -0
  135. package/src/benchmark/schema.ts +443 -0
  136. package/src/benchmark/suites.ts +323 -0
  137. package/src/benchmark/tinyQaDataset.ts +428 -0
  138. package/src/benchmark/types.ts +442 -0
  139. package/src/config.ts +44 -0
  140. package/src/index.ts +195 -0
  141. package/src/mcp/client.ts +305 -0
  142. package/src/mcp/discovery.ts +266 -0
  143. package/src/mcp/policy.ts +105 -0
  144. package/src/mcp/registry.ts +164 -0
  145. package/src/mcp/service.ts +611 -0
  146. package/src/middleware/auth.ts +251 -0
  147. package/src/middleware/requestCapture.ts +245 -0
  148. package/src/middleware/requestStats.ts +163 -0
  149. package/src/pools/builder.ts +159 -0
  150. package/src/pools/repository.ts +71 -0
  151. package/src/pools/scheduler.ts +425 -0
  152. package/src/pools/types.ts +117 -0
  153. package/src/protocols/adapters/dashscope.ts +335 -0
  154. package/src/protocols/adapters/inferenceV2.ts +428 -0
  155. package/src/protocols/adapters/openai.ts +32 -0
  156. package/src/protocols/registry.ts +117 -0
  157. package/src/protocols/types.ts +81 -0
  158. package/src/providers/health.ts +207 -0
  159. package/src/providers/importer.ts +402 -0
  160. package/src/providers/modelRegistry.ts +415 -0
  161. package/src/providers/repository.ts +439 -0
  162. package/src/providers/types.ts +113 -0
  163. package/src/routes/admin.ts +666 -0
  164. package/src/routes/audio.ts +372 -0
  165. package/src/routes/chat.ts +301 -0
  166. package/src/routes/embeddings.ts +197 -0
  167. package/src/routes/images.ts +356 -0
  168. package/src/routes/mcp.ts +320 -0
  169. package/src/routes/mcpService.ts +114 -0
  170. package/src/routes/models.ts +50 -0
  171. package/src/routes/responses.ts +872 -0
  172. package/src/routes/sessions.ts +558 -0
  173. package/src/routes/stats.ts +312 -0
  174. package/src/routes/ui.ts +96 -0
  175. package/src/routes/videos.ts +132 -0
  176. package/src/routing/router.ts +501 -0
  177. package/src/services/imageGeneration.ts +396 -0
  178. package/src/services/imageUnderstanding.ts +449 -0
  179. package/src/services/videoGeneration.ts +127 -0
  180. package/src/storage/captureRepository.ts +1835 -0
  181. package/src/storage/files.ts +178 -0
  182. package/src/storage/imageCache.ts +405 -0
  183. package/src/storage/repositories.ts +494 -0
  184. package/src/storage/sessionRepository.ts +419 -0
  185. package/src/storage/statsRepository.ts +238 -0
  186. package/src/transport/httpClient.ts +145 -0
  187. package/src/types.ts +322 -0
  188. package/src/utils/messageMedia.ts +293 -0
  189. package/src/utils/modelCapabilities.ts +161 -0
  190. package/src/utils/modelDiscovery.ts +203 -0
  191. package/src/workers/captureRetention.ts +25 -0
  192. package/src/workers/configWatcher.ts +115 -0
  193. package/src/workers/healthChecker.ts +22 -0
  194. package/src/workers/statsRotation.ts +49 -0
  195. package/tests/benchmarkAdminRoutes.test.ts +82 -0
  196. package/tests/benchmarkBasics.test.ts +116 -0
  197. package/tests/captureAdminRoutes.test.ts +420 -0
  198. package/tests/captureRepository.test.ts +797 -0
  199. package/tests/cliLegacyRewrite.test.ts +45 -0
  200. package/tests/imageGeneration.service.test.ts +107 -0
  201. package/tests/imageUnderstanding.service.test.ts +123 -0
  202. package/tests/mcpPolicy.test.ts +105 -0
  203. package/tests/mcpService.test.ts +1245 -0
  204. package/tests/modelRef.test.ts +23 -0
  205. package/tests/modelsRoutes.test.ts +154 -0
  206. package/tests/sessionMediaCache.test.ts +167 -0
  207. package/tests/statsRoutes.test.ts +323 -0
  208. package/tsconfig.json +15 -0
  209. package/ui/index.html +16 -0
  210. package/ui/package-lock.json +8521 -0
  211. package/ui/package.json +52 -0
  212. package/ui/postcss.config.js +6 -0
  213. package/ui/public/assets/apple-touch-icon.png +0 -0
  214. package/ui/public/assets/favicon-16.png +0 -0
  215. package/ui/public/assets/favicon-32.png +0 -0
  216. package/ui/public/assets/icon-192.png +0 -0
  217. package/ui/public/assets/icon-512.png +0 -0
  218. package/ui/src/App.tsx +27 -0
  219. package/ui/src/api/client.ts +1503 -0
  220. package/ui/src/components/EndpointUsageGuide.tsx +361 -0
  221. package/ui/src/components/Layout.tsx +124 -0
  222. package/ui/src/components/MessageContent.tsx +365 -0
  223. package/ui/src/components/ToolCallMessage.tsx +179 -0
  224. package/ui/src/components/ToolPicker.tsx +442 -0
  225. package/ui/src/components/messageContentParser.test.ts +41 -0
  226. package/ui/src/components/messageContentParser.ts +73 -0
  227. package/ui/src/components/thinkingPreview.test.ts +27 -0
  228. package/ui/src/components/thinkingPreview.ts +15 -0
  229. package/ui/src/components/toMermaidSankey.test.ts +78 -0
  230. package/ui/src/components/toMermaidSankey.ts +56 -0
  231. package/ui/src/components/ui/button.tsx +58 -0
  232. package/ui/src/components/ui/input.tsx +21 -0
  233. package/ui/src/components/ui/textarea.tsx +21 -0
  234. package/ui/src/lib/utils.ts +6 -0
  235. package/ui/src/main.tsx +9 -0
  236. package/ui/src/pages/AgentPlayground.tsx +2010 -0
  237. package/ui/src/pages/Benchmark.tsx +988 -0
  238. package/ui/src/pages/Dashboard.tsx +581 -0
  239. package/ui/src/pages/Peek.tsx +962 -0
  240. package/ui/src/pages/Settings.tsx +2013 -0
  241. package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
  242. package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
  243. package/ui/src/pages/agentThinkingContent.test.ts +50 -0
  244. package/ui/src/pages/agentThinkingContent.ts +57 -0
  245. package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
  246. package/ui/src/pages/dashboardTokenUsage.ts +36 -0
  247. package/ui/src/pages/imageUpload.test.ts +39 -0
  248. package/ui/src/pages/imageUpload.ts +71 -0
  249. package/ui/src/pages/peekFilters.test.ts +29 -0
  250. package/ui/src/pages/peekFilters.ts +13 -0
  251. package/ui/src/pages/peekMedia.test.ts +58 -0
  252. package/ui/src/pages/peekMedia.ts +148 -0
  253. package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
  254. package/ui/src/pages/sessionAutoTitle.ts +106 -0
  255. package/ui/src/stores/settings.ts +58 -0
  256. package/ui/src/styles/globals.css +223 -0
  257. package/ui/src/vite-env.d.ts +8 -0
  258. package/ui/tailwind.config.js +106 -0
  259. package/ui/tsconfig.json +32 -0
  260. package/ui/vite.config.ts +37 -0
@@ -0,0 +1,2013 @@
1
+ import { useEffect, useState, useCallback } from 'react'
2
+ import {
3
+ Settings as SettingsIcon,
4
+ ExternalLink,
5
+ Image as ImageIcon,
6
+ Check,
7
+ Server,
8
+ Code2,
9
+ ChevronDown,
10
+ ChevronUp,
11
+ Plus,
12
+ Pencil,
13
+ Trash2,
14
+ Power,
15
+ X,
16
+ Plug,
17
+ RefreshCw,
18
+ Search,
19
+ Cpu,
20
+ Palette,
21
+ Headphones,
22
+ BarChart3,
23
+ AlertCircle,
24
+ Layers,
25
+ } from 'lucide-react'
26
+ import { cn } from '@/lib/utils'
27
+ import { EndpointUsageGuide } from '@/components/EndpointUsageGuide'
28
+ import { Button } from '@/components/ui/button'
29
+ import { Input } from '@/components/ui/input'
30
+ import { Textarea } from '@/components/ui/textarea'
31
+ import {
32
+ addProvider,
33
+ addProviderModel,
34
+ deleteProvider,
35
+ deleteProviderModel,
36
+ discoverProviderModels,
37
+ type DiscoveredProviderModel,
38
+ disableProvider,
39
+ disableProviderModel,
40
+ enableProvider,
41
+ enableProviderModel,
42
+ getAdminMeta,
43
+ listProviders,
44
+ listProtocols,
45
+ updateProvider,
46
+ updateProviderModel,
47
+ type Provider,
48
+ type ProviderModel,
49
+ type ProtocolInfo,
50
+ type EndpointType,
51
+ type ModelModality,
52
+ listMcpServers,
53
+ addMcpServer,
54
+ deleteMcpServer,
55
+ updateMcpServer,
56
+ connectMcpServer,
57
+ type McpServer,
58
+ listVirtualModels,
59
+ createVirtualModel,
60
+ updateVirtualModel,
61
+ deleteVirtualModel,
62
+ toggleVirtualModel,
63
+ type VirtualModel,
64
+ } from '@/api/client'
65
+ import {
66
+ loadSettings,
67
+ updateSetting,
68
+ IMAGE_SIZE_OPTIONS,
69
+ type ImageSize,
70
+ type UserSettings,
71
+ } from '@/stores/settings'
72
+
73
+ const ENDPOINT_OPTIONS: EndpointType[] = ['llm', 'diffusion', 'audio', 'embedding']
74
+ const MODALITY_OPTIONS: ModelModality[] = ['text', 'image', 'audio', 'embedding']
75
+
76
+ type ProviderFormValues = {
77
+ id: string
78
+ name: string
79
+ baseUrl: string
80
+ protocol: string
81
+ enabled: boolean
82
+ supportsRouting: boolean
83
+ apiKey: string
84
+ description: string
85
+ docs: string
86
+ insecureTls: boolean
87
+ autoInsecureTlsDomains: string
88
+ envVar: string
89
+ authType: 'bearer' | 'query' | 'header' | 'none'
90
+ keyParam: string
91
+ headerName: string
92
+ keyPrefix: string
93
+ protocolConfigText: string
94
+ limitsText: string
95
+ }
96
+
97
+ type RateLimitRule = {
98
+ type: 'requests' | 'tokens'
99
+ value: string
100
+ unit: 'minute' | 'hour' | 'day' | 'week'
101
+ }
102
+
103
+ type ModelFormValues = {
104
+ providerId: string
105
+ modelId: string
106
+ upstreamModel: string
107
+ endpointType: EndpointType
108
+ enabled: boolean
109
+ baseUrl: string
110
+ apiKey: string
111
+ insecureTls: boolean
112
+ aliases: string
113
+ modalities: string
114
+ inputCapabilities: string
115
+ outputCapabilities: string
116
+ supportsTools: boolean
117
+ supportsStreaming: boolean
118
+ limitsText: string
119
+ rateLimitRules: RateLimitRule[]
120
+ }
121
+
122
+ export function Settings() {
123
+ const [settings, setSettings] = useState<UserSettings>(loadSettings)
124
+ const [providers, setProviders] = useState<Provider[]>([])
125
+ const [virtualModels, setVirtualModels] = useState<VirtualModel[]>([])
126
+ const [version, setVersion] = useState<string>('0.0.0')
127
+ const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set())
128
+ const [isLoadingProviders, setIsLoadingProviders] = useState(true)
129
+ const [providerError, setProviderError] = useState<string | null>(null)
130
+ const [busyKey, setBusyKey] = useState<string | null>(null)
131
+ const [providerForm, setProviderForm] = useState<{ mode: 'create' | 'edit'; initial?: Provider } | null>(null)
132
+ const [modelForm, setModelForm] = useState<{ provider: Provider; initial?: ProviderModel } | null>(null)
133
+ const [vmForm, setVmForm] = useState<{ mode: 'create' | 'edit'; initial?: VirtualModel } | null>(null)
134
+
135
+ const handleImageSizeChange = (size: ImageSize) => {
136
+ const updated = updateSetting('defaultImageSize', size)
137
+ setSettings(updated)
138
+ }
139
+
140
+ const loadData = async () => {
141
+ setIsLoadingProviders(true)
142
+ setProviderError(null)
143
+ try {
144
+ const [providerData, meta, vmData] = await Promise.all([listProviders(), getAdminMeta(), listVirtualModels()])
145
+ setProviders(providerData)
146
+ setVirtualModels(vmData)
147
+ setVersion(meta.version)
148
+ setExpandedProviders((previous) => {
149
+ const next = new Set<string>()
150
+ for (const provider of providerData) {
151
+ if (previous.has(provider.id)) next.add(provider.id)
152
+ }
153
+ return next
154
+ })
155
+ } catch (error) {
156
+ setProviderError(getErrorMessage(error, 'Failed to load providers'))
157
+ } finally {
158
+ setIsLoadingProviders(false)
159
+ }
160
+ }
161
+
162
+ useEffect(() => {
163
+ void loadData()
164
+ }, [])
165
+
166
+ const toggleProvider = (providerId: string) => {
167
+ setExpandedProviders((previous) => {
168
+ const next = new Set(previous)
169
+ if (next.has(providerId)) next.delete(providerId)
170
+ else next.add(providerId)
171
+ return next
172
+ })
173
+ }
174
+
175
+ const runProviderAction = async (key: string, action: () => Promise<void>) => {
176
+ setBusyKey(key)
177
+ setProviderError(null)
178
+ try {
179
+ await action()
180
+ await loadData()
181
+ } catch (error) {
182
+ setProviderError(getErrorMessage(error, 'Provider update failed'))
183
+ } finally {
184
+ setBusyKey(null)
185
+ }
186
+ }
187
+
188
+ return (
189
+ <div className="flex-1 flex flex-col h-full min-h-0">
190
+ <header className="sticky top-0 z-20 h-14 border-b border-border bg-background/95 backdrop-blur flex items-center px-6 gap-4 shrink-0">
191
+ <div className="flex items-center gap-2">
192
+ <SettingsIcon className="w-4 h-4 text-primary" />
193
+ <h2 className="font-mono font-semibold text-sm uppercase tracking-wider">Settings</h2>
194
+ </div>
195
+ </header>
196
+
197
+ <div className="flex-1 min-h-0 p-6 overflow-auto">
198
+ <div className="max-w-5xl space-y-6">
199
+ <div className="panel">
200
+ <div className="panel-header">
201
+ <ImageIcon className="w-4 h-4 text-muted-foreground" />
202
+ <span className="panel-title">Image Generation</span>
203
+ </div>
204
+ <div className="p-4 space-y-4">
205
+ <div>
206
+ <label className="text-sm font-medium block mb-2">Default Image Size</label>
207
+ <p className="text-xs text-muted-foreground mb-3">
208
+ Used when generating images via diffusion models. Can be overridden per-request in the playground.
209
+ </p>
210
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
211
+ {IMAGE_SIZE_OPTIONS.map((option) => (
212
+ <button
213
+ key={option.value}
214
+ onClick={() => handleImageSizeChange(option.value)}
215
+ className={cn(
216
+ 'relative flex flex-col items-center p-3 rounded-lg border transition-all text-left',
217
+ settings.defaultImageSize === option.value
218
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
219
+ : 'border-border hover:border-primary/50 hover:bg-secondary/50'
220
+ )}
221
+ >
222
+ {settings.defaultImageSize === option.value && (
223
+ <div className="absolute top-1.5 right-1.5">
224
+ <Check className="w-3.5 h-3.5 text-primary" />
225
+ </div>
226
+ )}
227
+ <span className="font-mono text-sm font-medium">{option.label}</span>
228
+ <span className="text-2xs text-muted-foreground">{option.aspect}</span>
229
+ </button>
230
+ ))}
231
+ </div>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <div className="panel">
237
+ <div className="panel-header">
238
+ <Server className="w-4 h-4 text-muted-foreground" />
239
+ <span className="panel-title">Providers & Models</span>
240
+ <span className="text-2xs text-muted-foreground ml-auto">{providers.length} providers</span>
241
+ <Button size="sm" variant="outline" onClick={() => setProviderForm({ mode: 'create' })}>
242
+ <Plus className="w-3.5 h-3.5 mr-1" />
243
+ Add Provider
244
+ </Button>
245
+ </div>
246
+ <div className="p-4 space-y-4">
247
+ {providerError && (
248
+ <div className="rounded border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
249
+ {providerError}
250
+ </div>
251
+ )}
252
+ {isLoadingProviders && <div className="text-sm text-muted-foreground">Loading providers…</div>}
253
+ {!isLoadingProviders && providers.length === 0 && (
254
+ <div className="rounded border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
255
+ No providers configured.
256
+ </div>
257
+ )}
258
+ {providers.map((provider) => {
259
+ const enabledModels = provider.models.filter((model) => model.enabled !== false)
260
+ const isOpen = expandedProviders.has(provider.id)
261
+ return (
262
+ <div key={provider.id} className="rounded-lg border border-border overflow-hidden">
263
+ <div className="px-4 py-3 flex items-start gap-3">
264
+ <button
265
+ onClick={() => toggleProvider(provider.id)}
266
+ className="flex-1 min-w-0 text-left"
267
+ >
268
+ <div className="flex items-center gap-2">
269
+ <div className={cn('status-dot', provider.enabled ? 'status-dot-live' : 'status-dot-down')} />
270
+ <p className="font-medium text-sm">{provider.id}</p>
271
+ <span className="text-2xs uppercase text-muted-foreground">{provider.protocol}</span>
272
+ </div>
273
+ <p className="text-2xs text-muted-foreground truncate font-mono mt-1">{provider.baseUrl}</p>
274
+ <p className="text-2xs text-muted-foreground mt-1">
275
+ {enabledModels.length}/{provider.models.length} models enabled
276
+ </p>
277
+ </button>
278
+ <div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
279
+ <Button
280
+ size="sm"
281
+ variant="outline"
282
+ disabled={busyKey === `provider-toggle:${provider.id}`}
283
+ onClick={() =>
284
+ void runProviderAction(`provider-toggle:${provider.id}`, async () => {
285
+ if (provider.enabled) await disableProvider(provider.id)
286
+ else await enableProvider(provider.id)
287
+ })
288
+ }
289
+ >
290
+ <Power className="w-3.5 h-3.5 mr-1" />
291
+ {provider.enabled ? 'Disable' : 'Enable'}
292
+ </Button>
293
+ <Button size="sm" variant="outline" onClick={() => setProviderForm({ mode: 'edit', initial: provider })}>
294
+ <Pencil className="w-3.5 h-3.5 mr-1" />
295
+ Edit
296
+ </Button>
297
+ <Button size="sm" variant="outline" onClick={() => setModelForm({ provider })}>
298
+ <Plus className="w-3.5 h-3.5 mr-1" />
299
+ Add Model
300
+ </Button>
301
+ <Button
302
+ size="sm"
303
+ variant="outline"
304
+ className="text-destructive hover:text-destructive"
305
+ disabled={busyKey === `provider-delete:${provider.id}`}
306
+ onClick={() => {
307
+ if (!window.confirm(`Delete provider ${provider.id} and all of its models?`)) return
308
+ void runProviderAction(`provider-delete:${provider.id}`, async () => {
309
+ await deleteProvider(provider.id)
310
+ })
311
+ }}
312
+ >
313
+ <Trash2 className="w-3.5 h-3.5 mr-1" />
314
+ Delete
315
+ </Button>
316
+ <button onClick={() => toggleProvider(provider.id)} className="p-1 text-muted-foreground">
317
+ {isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
318
+ </button>
319
+ </div>
320
+ </div>
321
+
322
+ {isOpen && (
323
+ <div className="border-t border-border bg-secondary/10">
324
+ <div className="px-4 py-3 grid grid-cols-2 gap-4 text-xs">
325
+ <ProviderMeta label="Supports Routing" value={provider.supportsRouting ? 'Yes' : 'No'} />
326
+ <ProviderMeta label="Imported" value={provider.importedAt ?? 'n/a'} mono />
327
+ <ProviderMeta label="Auth" value={provider.auth?.type ?? 'none'} />
328
+ <ProviderMeta label="Env Var" value={provider.envVar || 'n/a'} mono />
329
+ </div>
330
+ {provider.warnings && provider.warnings.length > 0 && (
331
+ <div className="px-4 pb-3 text-xs text-amber-300 space-y-1">
332
+ {provider.warnings.map((warning, index) => (
333
+ <div key={index}>{warning}</div>
334
+ ))}
335
+ </div>
336
+ )}
337
+ <div className="px-4 pb-4 space-y-3">
338
+ {provider.models.length === 0 && (
339
+ <p className="text-xs text-muted-foreground">No models configured for this provider.</p>
340
+ )}
341
+ {provider.models.map((model) => {
342
+ const canonical = `${provider.id}/${model.modelId}`
343
+ return (
344
+ <div key={model.providerModelId} className="border border-border/70 rounded-md bg-background">
345
+ <div className="px-3 py-2 flex items-start gap-3">
346
+ <div className="flex-1 min-w-0">
347
+ <div className="flex items-center gap-2 flex-wrap">
348
+ <Code2 className="w-3.5 h-3.5 text-muted-foreground" />
349
+ <p className="text-xs font-mono">{canonical}</p>
350
+ <span className="text-2xs uppercase text-muted-foreground">{model.endpointType}</span>
351
+ <span className="text-2xs text-muted-foreground">upstream {model.upstreamModel}</span>
352
+ </div>
353
+ <div className="text-2xs text-muted-foreground mt-1">
354
+ {(model.modalities ?? []).join(', ') || 'no modalities'}
355
+ </div>
356
+ </div>
357
+ <div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
358
+ <Button
359
+ size="sm"
360
+ variant="outline"
361
+ disabled={busyKey === `model-toggle:${model.providerModelId}`}
362
+ onClick={() =>
363
+ void runProviderAction(`model-toggle:${model.providerModelId}`, async () => {
364
+ if (model.enabled === false) await enableProviderModel(provider.id, model.providerModelId)
365
+ else await disableProviderModel(provider.id, model.providerModelId)
366
+ })
367
+ }
368
+ >
369
+ <Power className="w-3.5 h-3.5 mr-1" />
370
+ {model.enabled === false ? 'Enable' : 'Disable'}
371
+ </Button>
372
+ <Button size="sm" variant="outline" onClick={() => setModelForm({ provider, initial: model })}>
373
+ <Pencil className="w-3.5 h-3.5 mr-1" />
374
+ Edit
375
+ </Button>
376
+ <Button
377
+ size="sm"
378
+ variant="outline"
379
+ className="text-destructive hover:text-destructive"
380
+ disabled={busyKey === `model-delete:${model.providerModelId}`}
381
+ onClick={() => {
382
+ if (!window.confirm(`Delete model ${canonical}?`)) return
383
+ void runProviderAction(`model-delete:${model.providerModelId}`, async () => {
384
+ await deleteProviderModel(provider.id, model.providerModelId)
385
+ })
386
+ }}
387
+ >
388
+ <Trash2 className="w-3.5 h-3.5 mr-1" />
389
+ Delete
390
+ </Button>
391
+ </div>
392
+ </div>
393
+ <EndpointUsageGuide
394
+ target={{
395
+ id: model.providerModelId,
396
+ type: model.endpointType,
397
+ models: [
398
+ { publicName: canonical },
399
+ ...(model.aliases ?? []).map((alias) => ({ publicName: alias })),
400
+ ],
401
+ }}
402
+ />
403
+ </div>
404
+ )
405
+ })}
406
+ </div>
407
+ </div>
408
+ )}
409
+ </div>
410
+ )
411
+ })}
412
+ </div>
413
+ </div>
414
+
415
+ <VirtualModelsPanel
416
+ virtualModels={virtualModels}
417
+ providers={providers}
418
+ onReload={() => void loadData()}
419
+ onCreate={() => setVmForm({ mode: 'create' })}
420
+ onEdit={(vm) => setVmForm({ mode: 'edit', initial: vm })}
421
+ />
422
+
423
+ <McpServersPanel />
424
+
425
+ <div className="panel">
426
+ <div className="panel-header">
427
+ <span className="panel-title">About Waypoi</span>
428
+ </div>
429
+ <div className="p-4 space-y-4">
430
+ <p className="text-sm text-muted-foreground">
431
+ Waypoi is a provider-first local AI gateway. It provides an OpenAI-compatible
432
+ API over multiple providers/models, with routing, failover, and observability.
433
+ </p>
434
+ <div className="grid grid-cols-2 gap-4">
435
+ <div>
436
+ <p className="text-2xs font-mono uppercase text-muted-foreground">Version</p>
437
+ <p className="font-mono">{version}</p>
438
+ </div>
439
+ <div>
440
+ <p className="text-2xs font-mono uppercase text-muted-foreground">Config Path</p>
441
+ <p className="font-mono text-sm truncate">~/.config/waypoi/providers.json</p>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </div>
446
+
447
+ <div className="panel">
448
+ <div className="panel-header">
449
+ <span className="panel-title">CLI Commands</span>
450
+ </div>
451
+ <div className="p-4 space-y-3">
452
+ <CommandRow command="waypoi providers import -f .env" description="Import providers and credentials" />
453
+ <CommandRow command="waypoi providers" description="List providers" />
454
+ <CommandRow command="waypoi models <providerId>" description="List models in one provider" />
455
+ <CommandRow command="waypoi models add <providerId> ..." description="Add a provider-owned model" />
456
+ <CommandRow command="waypoi models update <providerId> <modelRef>" description="Update model routing/capabilities/auth" />
457
+ <CommandRow command="waypoi bench" description="Run lightweight benchmark suite" />
458
+ <CommandRow command="waypoi chat --model smart" description="Chat from the terminal (server must be running)" />
459
+ <CommandRow command="waypoi sessions" description="List all chat sessions" />
460
+ <CommandRow command="waypoi sessions show <id>" description="Print message history for a session" />
461
+ <CommandRow command="waypoi mcp add --name mytools --url http://..." description="Register an MCP server" />
462
+ </div>
463
+ </div>
464
+
465
+ <div className="panel">
466
+ <div className="panel-header">
467
+ <span className="panel-title">Resources</span>
468
+ </div>
469
+ <div className="p-4 space-y-2">
470
+ <a
471
+ href="https://github.com/ziangziangziang/waypoi"
472
+ target="_blank"
473
+ rel="noopener noreferrer"
474
+ className="flex items-center gap-2 text-sm text-primary hover:underline"
475
+ >
476
+ <ExternalLink className="w-3 h-3" />
477
+ GitHub Repository
478
+ </a>
479
+ </div>
480
+ </div>
481
+ </div>
482
+ </div>
483
+
484
+ {providerForm && (
485
+ <ProviderFormDialog
486
+ title={providerForm.mode === 'create' ? 'Add Provider' : `Edit Provider ${providerForm.initial?.id}`}
487
+ initialValues={providerForm.mode === 'create' ? emptyProviderForm() : providerToForm(providerForm.initial!)}
488
+ isEdit={providerForm.mode === 'edit'}
489
+ onClose={() => setProviderForm(null)}
490
+ onSubmit={async (values) => {
491
+ const payload = parseProviderForm(values)
492
+ await runProviderAction(`provider-form:${values.id}`, async () => {
493
+ if (providerForm.mode === 'create') await addProvider(payload)
494
+ else await updateProvider(providerForm.initial!.id, payload)
495
+ })
496
+ setProviderForm(null)
497
+ }}
498
+ />
499
+ )}
500
+
501
+ {modelForm && (
502
+ <ModelFormDialog
503
+ title={modelForm.initial ? `Edit Model ${modelForm.initial.providerModelId}` : `Add Model to ${modelForm.provider.id}`}
504
+ provider={modelForm.provider}
505
+ allowDiscovery={!modelForm.initial}
506
+ initialValues={modelForm.initial ? modelToForm(modelForm.provider, modelForm.initial) : emptyModelForm(modelForm.provider.id)}
507
+ onClose={() => setModelForm(null)}
508
+ onSubmit={async (values) => {
509
+ const payload = parseModelForm(values)
510
+ await runProviderAction(`model-form:${values.providerId}:${values.modelId}`, async () => {
511
+ if (modelForm.initial) {
512
+ await updateProviderModel(modelForm.provider.id, modelForm.initial.providerModelId, payload)
513
+ } else {
514
+ await addProviderModel(modelForm.provider.id, payload)
515
+ }
516
+ })
517
+ setModelForm(null)
518
+ }}
519
+ />
520
+ )}
521
+
522
+ {vmForm && (
523
+ <VirtualModelFormDialog
524
+ title={vmForm.mode === 'create' ? 'Create Virtual Model' : `Edit Virtual Model ${vmForm.initial?.id}`}
525
+ initialValues={vmForm.mode === 'create' ? emptyVmForm() : vmToForm(vmForm.initial!)}
526
+ allProviderModels={providers.flatMap((p) =>
527
+ p.models.map((m) => ({
528
+ key: `${p.id}/${m.modelId}`,
529
+ providerId: p.id,
530
+ modelId: m.modelId,
531
+ endpointType: m.endpointType,
532
+ modalities: m.modalities ?? [],
533
+ capabilities: m.capabilities,
534
+ }))
535
+ )}
536
+ isEdit={vmForm.mode === 'edit'}
537
+ onClose={() => setVmForm(null)}
538
+ onSubmit={async (values) => {
539
+ const payload = parseVmForm(values)
540
+ if (vmForm.mode === 'create') {
541
+ await createVirtualModel(payload)
542
+ } else {
543
+ await updateVirtualModel(vmForm.initial!.id, payload)
544
+ }
545
+ setVmForm(null)
546
+ await loadData()
547
+ }}
548
+ />
549
+ )}
550
+ </div>
551
+ )
552
+ }
553
+
554
+ function ProviderMeta(props: { label: string; value: string; mono?: boolean }) {
555
+ return (
556
+ <div>
557
+ <p className="text-2xs font-mono uppercase text-muted-foreground">{props.label}</p>
558
+ <p className={cn('text-sm truncate', props.mono && 'font-mono')}>{props.value}</p>
559
+ </div>
560
+ )
561
+ }
562
+
563
+ const OPERATION_LABELS: Record<string, string> = {
564
+ chat_completions: 'Chat',
565
+ embeddings: 'Embeddings',
566
+ images_generation: 'Images',
567
+ images_edits: 'Image Edits',
568
+ images_variations: 'Image Vars',
569
+ audio_transcriptions: 'Transcribe',
570
+ audio_translations: 'Translate',
571
+ audio_speech: 'Speech',
572
+ }
573
+
574
+ function OperationBadge({ operation }: { operation: string }) {
575
+ const label = OPERATION_LABELS[operation] ?? operation
576
+ return (
577
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-secondary text-muted-foreground border border-border">
578
+ {label}
579
+ </span>
580
+ )
581
+ }
582
+
583
+ function ProviderFormDialog(props: {
584
+ title: string
585
+ initialValues: ProviderFormValues
586
+ isEdit: boolean
587
+ onClose: () => void
588
+ onSubmit: (values: ProviderFormValues) => Promise<void>
589
+ }) {
590
+ const [values, setValues] = useState<ProviderFormValues>(props.initialValues)
591
+ const [error, setError] = useState<string | null>(null)
592
+ const [saving, setSaving] = useState(false)
593
+ const [protocols, setProtocols] = useState<ProtocolInfo[]>([])
594
+ const [detecting, setDetecting] = useState(false)
595
+ const [detectResult, setDetectResult] = useState<'success' | 'warning' | 'error' | null>(null)
596
+ const [detectMessage, setDetectMessage] = useState<string | null>(null)
597
+ const [protocolFilter, setProtocolFilter] = useState('')
598
+
599
+ useEffect(() => {
600
+ void listProtocols().then(setProtocols).catch(() => {
601
+ setProtocols([
602
+ { id: 'openai', label: 'OpenAI Compatible', description: 'Standard OpenAI API format.', operations: ['chat_completions', 'embeddings', 'images_generation'], streamOperations: ['chat_completions', 'embeddings', 'images_generation'], supportsRouting: true },
603
+ { id: 'inference_v2', label: 'Inference V2 (KServe/Ray)', description: 'KServe v2 / Ray Serve inference format.', operations: ['chat_completions'], streamOperations: [], supportsRouting: true },
604
+ ])
605
+ })
606
+ }, [])
607
+
608
+ const setField = <K extends keyof ProviderFormValues>(key: K, value: ProviderFormValues[K]) => {
609
+ setValues((current) => ({ ...current, [key]: value }))
610
+ }
611
+
612
+ useEffect(() => {
613
+ if (!props.isEdit && values.id && !values.name) {
614
+ setValues((current) => ({ ...current, name: current.id }))
615
+ }
616
+ }, [values.id, values.name, props.isEdit])
617
+
618
+ const handleProtocolSelect = (protocolId: string) => {
619
+ const proto = protocols.find((p) => p.id === protocolId)
620
+ setValues((current) => ({
621
+ ...current,
622
+ protocol: protocolId,
623
+ supportsRouting: proto?.supportsRouting ?? current.supportsRouting,
624
+ }))
625
+ }
626
+
627
+ const handleAutoDetect = async () => {
628
+ if (!values.baseUrl.trim()) {
629
+ setDetectResult('error')
630
+ setDetectMessage('Enter a Base URL first')
631
+ return
632
+ }
633
+ setDetecting(true)
634
+ setDetectResult(null)
635
+ setDetectMessage(null)
636
+ setError(null)
637
+ try {
638
+ const normalizedBaseUrl = values.baseUrl.trim().replace(/\/+$/, '')
639
+ const headers: Record<string, string> = {}
640
+ if (values.apiKey.trim()) {
641
+ headers.authorization = `Bearer ${values.apiKey.trim()}`
642
+ }
643
+
644
+ let detected: string | null = null
645
+ let confidence: 'high' | 'low' = 'low'
646
+ try {
647
+ const v1Resp = await fetch(`${normalizedBaseUrl}/v1/models`, { headers })
648
+ if (v1Resp.ok) {
649
+ const json = await v1Resp.json()
650
+ if (json && typeof json === 'object' && Array.isArray((json as { data?: unknown }).data)) {
651
+ detected = 'openai'
652
+ confidence = 'high'
653
+ }
654
+ } else if (v1Resp.status === 401 || v1Resp.status === 403) {
655
+ detected = 'openai'
656
+ confidence = 'low'
657
+ }
658
+ } catch {
659
+ // ignore
660
+ }
661
+
662
+ if (!detected) {
663
+ try {
664
+ const v2Resp = await fetch(`${normalizedBaseUrl}/v2/models`, { headers })
665
+ if (v2Resp.ok) {
666
+ detected = 'inference_v2'
667
+ confidence = 'high'
668
+ }
669
+ } catch {
670
+ // ignore
671
+ }
672
+ }
673
+
674
+ if (detected && protocols.some((p) => p.id === detected)) {
675
+ handleProtocolSelect(detected)
676
+ setDetectResult(confidence === 'high' ? 'success' : 'warning')
677
+ const label = protocols.find((p) => p.id === detected)?.label ?? detected
678
+ setDetectMessage(confidence === 'high' ? `Detected: ${label}` : `Likely ${label} (auth required to confirm)`)
679
+ } else if (detected) {
680
+ setField('protocol', detected)
681
+ setDetectResult('warning')
682
+ setDetectMessage(`Detected: ${detected} (not in registry)`)
683
+ } else {
684
+ setDetectResult('error')
685
+ setDetectMessage('Could not detect API format. Select manually below.')
686
+ }
687
+ } catch (err) {
688
+ setDetectResult('error')
689
+ setDetectMessage('Detection failed. Select manually.')
690
+ } finally {
691
+ setDetecting(false)
692
+ }
693
+ }
694
+
695
+ const filteredProtocols = protocols.filter((p) =>
696
+ p.label.toLowerCase().includes(protocolFilter.toLowerCase()) ||
697
+ p.description.toLowerCase().includes(protocolFilter.toLowerCase())
698
+ )
699
+
700
+ return (
701
+ <Overlay title={props.title} onClose={props.onClose}>
702
+ <div className="space-y-4">
703
+ {error && <div className="text-sm text-destructive">{error}</div>}
704
+
705
+ <div className="grid grid-cols-2 gap-3">
706
+ <LabeledField label="Provider ID" description="Unique identifier, e.g. openrouter">
707
+ <Input value={values.id} onChange={(event) => setField('id', event.target.value)} disabled={props.isEdit} />
708
+ </LabeledField>
709
+ <LabeledField label="Display Name" description="Friendly name (auto-filled from ID)">
710
+ <Input value={values.name} onChange={(event) => setField('name', event.target.value)} />
711
+ </LabeledField>
712
+ </div>
713
+
714
+ <LabeledField
715
+ label="Base URL"
716
+ description={
717
+ <>
718
+ <span className="block font-mono">Examples: https://api.openai.com/v1, http://localhost:11434</span>
719
+ <span className="block">Discovery uses <code>/v1/models</code>, so root URLs and URLs already ending in <code>/v1</code> both work.</span>
720
+ </>
721
+ }
722
+ >
723
+ <div className="flex gap-2">
724
+ <Input className="flex-1" value={values.baseUrl} onChange={(event) => setField('baseUrl', event.target.value)} />
725
+ <Button
726
+ variant="outline"
727
+ size="sm"
728
+ onClick={() => void handleAutoDetect()}
729
+ disabled={detecting}
730
+ className="shrink-0 whitespace-nowrap"
731
+ >
732
+ <Search className="w-3.5 h-3.5 mr-1" />
733
+ {detecting ? 'Detecting…' : 'Auto-Detect'}
734
+ </Button>
735
+ </div>
736
+ </LabeledField>
737
+
738
+ {detectResult && (
739
+ <div className={cn(
740
+ 'flex items-center gap-2 rounded border px-3 py-2 text-xs',
741
+ detectResult === 'success' && 'border-green-500/40 bg-green-500/10 text-green-400',
742
+ detectResult === 'warning' && 'border-amber-500/40 bg-amber-500/10 text-amber-400',
743
+ detectResult === 'error' && 'border-destructive/40 bg-destructive/10 text-destructive',
744
+ )}>
745
+ {detectResult === 'success' && <Check className="w-3.5 h-3.5 shrink-0" />}
746
+ {detectResult === 'warning' && <AlertCircle className="w-3.5 h-3.5 shrink-0" />}
747
+ {detectResult === 'error' && <AlertCircle className="w-3.5 h-3.5 shrink-0" />}
748
+ {detectMessage}
749
+ </div>
750
+ )}
751
+
752
+ <div>
753
+ <label className="block space-y-1">
754
+ <span className="text-xs font-mono uppercase text-muted-foreground">API Format</span>
755
+ <span className="block text-xs text-muted-foreground">Select the API format this provider uses.</span>
756
+ </label>
757
+ {protocols.length > 4 && (
758
+ <Input
759
+ className="mt-2 h-8 text-sm"
760
+ placeholder="Search formats…"
761
+ value={protocolFilter}
762
+ onChange={(e) => setProtocolFilter(e.target.value)}
763
+ />
764
+ )}
765
+ <div className={cn('grid gap-2 mt-2', protocols.length > 2 ? 'grid-cols-2' : 'grid-cols-1')}>
766
+ {filteredProtocols.map((proto) => {
767
+ const isSelected = proto.id === values.protocol
768
+ return (
769
+ <button
770
+ key={proto.id}
771
+ type="button"
772
+ onClick={() => handleProtocolSelect(proto.id)}
773
+ className={cn(
774
+ 'rounded-lg border p-3 text-left transition-all',
775
+ isSelected
776
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
777
+ : 'border-border hover:border-primary/50 hover:bg-secondary/50'
778
+ )}
779
+ >
780
+ <div className="flex items-center justify-between">
781
+ <div className="flex items-center gap-2">
782
+ {isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
783
+ <span className="font-medium text-sm">{proto.label}</span>
784
+ </div>
785
+ <div className="flex items-center gap-1.5 shrink-0">
786
+ <span className={cn('text-2xs px-1.5 py-0.5 rounded', proto.supportsRouting ? 'bg-green-500/10 text-green-400' : 'bg-muted text-muted-foreground')}>
787
+ Routing
788
+ </span>
789
+ {proto.streamOperations.length > 0 && (
790
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
791
+ Stream
792
+ </span>
793
+ )}
794
+ </div>
795
+ </div>
796
+ <p className="text-xs text-muted-foreground mt-1">{proto.description}</p>
797
+ <div className="flex flex-wrap gap-1 mt-2">
798
+ {proto.operations.slice(0, 4).map((op) => (
799
+ <OperationBadge key={op} operation={op} />
800
+ ))}
801
+ {proto.operations.length > 4 && (
802
+ <span className="text-2xs px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">
803
+ +{proto.operations.length - 4}
804
+ </span>
805
+ )}
806
+ </div>
807
+ </button>
808
+ )
809
+ })}
810
+ </div>
811
+ {protocolFilter && filteredProtocols.length === 0 && (
812
+ <p className="text-xs text-muted-foreground mt-2">No formats match "{protocolFilter}"</p>
813
+ )}
814
+ </div>
815
+
816
+ <div className="grid grid-cols-2 gap-3">
817
+ <ToggleField label="Enabled" checked={values.enabled} onChange={(checked) => setField('enabled', checked)} />
818
+ <LabeledField label="API Key Override">
819
+ <Input value={values.apiKey} onChange={(event) => setField('apiKey', event.target.value)} />
820
+ </LabeledField>
821
+ </div>
822
+
823
+ <details className="rounded border border-border p-3">
824
+ <summary className="cursor-pointer text-sm font-medium">Advanced Provider Fields</summary>
825
+ <div className="space-y-3 mt-3">
826
+ <div className="grid grid-cols-2 gap-3">
827
+ <ToggleField label="Insecure TLS" checked={values.insecureTls} onChange={(checked) => setField('insecureTls', checked)} />
828
+ <LabeledField label="Env Var">
829
+ <Input value={values.envVar} onChange={(event) => setField('envVar', event.target.value)} />
830
+ </LabeledField>
831
+ <LabeledField label="Docs URL">
832
+ <Input value={values.docs} onChange={(event) => setField('docs', event.target.value)} />
833
+ </LabeledField>
834
+ <LabeledField label="Auth Type">
835
+ <select className="w-full h-9 rounded-md border border-input bg-transparent px-3 text-sm" value={values.authType} onChange={(event) => setField('authType', event.target.value as ProviderFormValues['authType'])}>
836
+ <option value="bearer">bearer</option>
837
+ <option value="header">header</option>
838
+ <option value="query">query</option>
839
+ <option value="none">none</option>
840
+ </select>
841
+ </LabeledField>
842
+ <LabeledField label="Header Name">
843
+ <Input value={values.headerName} onChange={(event) => setField('headerName', event.target.value)} />
844
+ </LabeledField>
845
+ <LabeledField label="Query Param">
846
+ <Input value={values.keyParam} onChange={(event) => setField('keyParam', event.target.value)} />
847
+ </LabeledField>
848
+ <LabeledField label="Key Prefix">
849
+ <Input value={values.keyPrefix} onChange={(event) => setField('keyPrefix', event.target.value)} />
850
+ </LabeledField>
851
+ <LabeledField label="Auto Insecure TLS Domains (comma-separated)">
852
+ <Input value={values.autoInsecureTlsDomains} onChange={(event) => setField('autoInsecureTlsDomains', event.target.value)} />
853
+ </LabeledField>
854
+ </div>
855
+ <LabeledField label="Description">
856
+ <Textarea value={values.description} onChange={(event) => setField('description', event.target.value)} rows={3} />
857
+ </LabeledField>
858
+ <LabeledField label="Protocol Config (JSON)">
859
+ <Textarea value={values.protocolConfigText} onChange={(event) => setField('protocolConfigText', event.target.value)} rows={4} />
860
+ </LabeledField>
861
+ <LabeledField label="Limits (JSON)">
862
+ <Textarea value={values.limitsText} onChange={(event) => setField('limitsText', event.target.value)} rows={4} />
863
+ </LabeledField>
864
+ </div>
865
+ </details>
866
+ <div className="flex justify-end gap-2">
867
+ <Button variant="outline" onClick={props.onClose} disabled={saving}>Cancel</Button>
868
+ <Button
869
+ onClick={async () => {
870
+ setSaving(true)
871
+ setError(null)
872
+ try {
873
+ await props.onSubmit(values)
874
+ } catch (err) {
875
+ setError(getErrorMessage(err, 'Failed to save provider'))
876
+ } finally {
877
+ setSaving(false)
878
+ }
879
+ }}
880
+ disabled={saving}
881
+ >
882
+ Save Provider
883
+ </Button>
884
+ </div>
885
+ </div>
886
+ </Overlay>
887
+ )
888
+ }
889
+
890
+ const ENDPOINT_TYPE_INFO: Record<EndpointType, { label: string; description: string; icon: typeof Cpu }> = {
891
+ llm: { label: 'LLM', description: 'Text generation, chat, reasoning', icon: Cpu },
892
+ diffusion: { label: 'Image', description: 'Image generation and editing', icon: Palette },
893
+ audio: { label: 'Audio', description: 'Speech-to-text, text-to-speech', icon: Headphones },
894
+ embedding: { label: 'Embedding', description: 'Vector embeddings for search/RAG', icon: BarChart3 },
895
+ }
896
+
897
+ function ModelFormDialog(props: {
898
+ title: string
899
+ provider: Provider
900
+ allowDiscovery: boolean
901
+ initialValues: ModelFormValues
902
+ onClose: () => void
903
+ onSubmit: (values: ModelFormValues) => Promise<void>
904
+ }) {
905
+ const [values, setValues] = useState<ModelFormValues>(props.initialValues)
906
+ const [error, setError] = useState<string | null>(null)
907
+ const [saving, setSaving] = useState(false)
908
+ const [discovering, setDiscovering] = useState(false)
909
+ const [discoveryError, setDiscoveryError] = useState<string | null>(null)
910
+ const [discoveryBaseUrl, setDiscoveryBaseUrl] = useState<string | null>(null)
911
+ const [discoveredModels, setDiscoveredModels] = useState<DiscoveredProviderModel[]>([])
912
+ const [showAdvanced, setShowAdvanced] = useState(false)
913
+
914
+ const setField = <K extends keyof ModelFormValues>(key: K, value: ModelFormValues[K]) => {
915
+ setValues((current) => ({ ...current, [key]: value }))
916
+ }
917
+
918
+ const handleDiscovery = async () => {
919
+ setDiscovering(true)
920
+ setDiscoveryError(null)
921
+ try {
922
+ const response = await discoverProviderModels(props.provider.id, {
923
+ baseUrl: values.baseUrl.trim() || undefined,
924
+ apiKey: values.apiKey.trim() || undefined,
925
+ insecureTls: values.insecureTls || undefined,
926
+ })
927
+ setDiscoveryBaseUrl(response.baseUrl)
928
+ setDiscoveredModels(response.models)
929
+ } catch (err) {
930
+ setDiscoveryError(getErrorMessage(err, 'Failed to discover models'))
931
+ } finally {
932
+ setDiscovering(false)
933
+ }
934
+ }
935
+
936
+ const applyDiscoveredModel = (model: DiscoveredProviderModel) => {
937
+ setValues((current) => {
938
+ const next: ModelFormValues = {
939
+ ...current,
940
+ modelId: model.id,
941
+ upstreamModel: model.id,
942
+ }
943
+ if (!model.capabilities) {
944
+ return next
945
+ }
946
+ if (model.capabilities.input.length > 0) {
947
+ next.inputCapabilities = model.capabilities.input.join(', ')
948
+ }
949
+ if (model.capabilities.output.length > 0) {
950
+ next.outputCapabilities = model.capabilities.output.join(', ')
951
+ next.endpointType = inferEndpointType(model.capabilities.output)
952
+ }
953
+ const modalities = mergeModalities(model.capabilities)
954
+ if (modalities.length > 0) {
955
+ next.modalities = modalities.join(', ')
956
+ }
957
+ if (typeof model.capabilities.supportsTools === 'boolean') {
958
+ next.supportsTools = model.capabilities.supportsTools
959
+ }
960
+ if (typeof model.capabilities.supportsStreaming === 'boolean') {
961
+ next.supportsStreaming = model.capabilities.supportsStreaming
962
+ }
963
+ return next
964
+ })
965
+ }
966
+
967
+ const toggleModality = (field: 'inputCapabilities' | 'outputCapabilities', modality: ModelModality) => {
968
+ const current = new Set(parseCommaList(values[field]))
969
+ if (current.has(modality)) current.delete(modality)
970
+ else current.add(modality)
971
+ setField(field, Array.from(current).join(', '))
972
+ }
973
+
974
+ const inputModalities = new Set(parseCommaList(values.inputCapabilities))
975
+ const outputModalities = new Set(parseCommaList(values.outputCapabilities))
976
+
977
+ return (
978
+ <Overlay title={props.title} onClose={props.onClose}>
979
+ <div className="space-y-4">
980
+ {error && <div className="text-sm text-destructive">{error}</div>}
981
+
982
+ {props.allowDiscovery && (
983
+ <div className="rounded border border-border p-3 space-y-3">
984
+ <div className="flex items-center justify-between gap-3">
985
+ <div>
986
+ <p className="text-sm font-medium">Discover Models</p>
987
+ <p className="text-xs text-muted-foreground">
988
+ Fetch available models from <code>/v1/models</code>. Pick one to auto-fill the form.
989
+ </p>
990
+ </div>
991
+ <Button variant="outline" onClick={() => void handleDiscovery()} disabled={discovering || saving}>
992
+ {discovering ? 'Discovering…' : 'Discover'}
993
+ </Button>
994
+ </div>
995
+ {discoveryError && <div className="text-sm text-destructive">{discoveryError}</div>}
996
+ {discoveryBaseUrl && (
997
+ <div className="text-xs text-muted-foreground">
998
+ Source: <code>{discoveryBaseUrl}</code>
999
+ </div>
1000
+ )}
1001
+ {discoveryBaseUrl && discoveredModels.length === 0 && !discoveryError && (
1002
+ <div className="text-sm text-muted-foreground">No models returned by upstream <code>/v1/models</code>.</div>
1003
+ )}
1004
+ {discoveredModels.length > 0 && (
1005
+ <div className="space-y-1.5 max-h-48 overflow-auto pr-1">
1006
+ {discoveredModels.map((model) => (
1007
+ <button
1008
+ key={model.id}
1009
+ type="button"
1010
+ onClick={() => applyDiscoveredModel(model)}
1011
+ className="w-full rounded border border-border px-3 py-2 text-left hover:border-primary/50 hover:bg-secondary/40 transition-colors"
1012
+ >
1013
+ <div className="font-mono text-sm">{model.id}</div>
1014
+ <div className="text-xs text-muted-foreground mt-1">
1015
+ {formatDiscoveryCapabilities(model)}
1016
+ </div>
1017
+ </button>
1018
+ ))}
1019
+ </div>
1020
+ )}
1021
+ </div>
1022
+ )}
1023
+
1024
+ <div className="grid grid-cols-2 gap-3">
1025
+ <LabeledField label="Model ID" description="The model identifier used in API requests">
1026
+ <Input
1027
+ value={values.modelId}
1028
+ onChange={(event) => {
1029
+ const v = event.target.value
1030
+ setValues((current) => ({
1031
+ ...current,
1032
+ modelId: v,
1033
+ upstreamModel: current.upstreamModel === current.modelId ? v : current.upstreamModel,
1034
+ }))
1035
+ }}
1036
+ />
1037
+ </LabeledField>
1038
+ <LabeledField label="Endpoint Type">
1039
+ <div className="grid grid-cols-2 gap-1.5">
1040
+ {ENDPOINT_OPTIONS.map((option) => {
1041
+ const info = ENDPOINT_TYPE_INFO[option]
1042
+ const Icon = info.icon
1043
+ const isSelected = values.endpointType === option
1044
+ return (
1045
+ <button
1046
+ key={option}
1047
+ type="button"
1048
+ onClick={() => setField('endpointType', option)}
1049
+ className={cn(
1050
+ 'flex items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-all',
1051
+ isSelected
1052
+ ? 'border-primary bg-primary/5 text-primary'
1053
+ : 'border-border hover:border-primary/50 text-muted-foreground'
1054
+ )}
1055
+ >
1056
+ <Icon className="w-3.5 h-3.5 shrink-0" />
1057
+ <span className="font-medium">{info.label}</span>
1058
+ </button>
1059
+ )
1060
+ })}
1061
+ </div>
1062
+ </LabeledField>
1063
+ </div>
1064
+
1065
+ <div>
1066
+ <label className="block">
1067
+ <span className="text-xs font-mono uppercase text-muted-foreground">Capabilities</span>
1068
+ </label>
1069
+ <div className="grid grid-cols-2 gap-3 mt-2">
1070
+ <div className="rounded border border-border p-2.5 space-y-1.5">
1071
+ <p className="text-2xs font-mono uppercase text-muted-foreground">Input</p>
1072
+ <div className="flex flex-wrap gap-1.5">
1073
+ {MODALITY_OPTIONS.map((m) => {
1074
+ const active = inputModalities.has(m)
1075
+ return (
1076
+ <button
1077
+ key={`in-${m}`}
1078
+ type="button"
1079
+ onClick={() => toggleModality('inputCapabilities', m)}
1080
+ className={cn(
1081
+ 'text-xs px-2 py-1 rounded border transition-all',
1082
+ active ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:border-primary/50'
1083
+ )}
1084
+ >
1085
+ {m}
1086
+ </button>
1087
+ )
1088
+ })}
1089
+ </div>
1090
+ </div>
1091
+ <div className="rounded border border-border p-2.5 space-y-1.5">
1092
+ <p className="text-2xs font-mono uppercase text-muted-foreground">Output</p>
1093
+ <div className="flex flex-wrap gap-1.5">
1094
+ {MODALITY_OPTIONS.map((m) => {
1095
+ const active = outputModalities.has(m)
1096
+ return (
1097
+ <button
1098
+ key={`out-${m}`}
1099
+ type="button"
1100
+ onClick={() => toggleModality('outputCapabilities', m)}
1101
+ className={cn(
1102
+ 'text-xs px-2 py-1 rounded border transition-all',
1103
+ active ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:border-primary/50'
1104
+ )}
1105
+ >
1106
+ {m}
1107
+ </button>
1108
+ )
1109
+ })}
1110
+ </div>
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+
1115
+ <div className="grid grid-cols-2 gap-3">
1116
+ <ToggleField label="Enabled" checked={values.enabled} onChange={(checked) => setField('enabled', checked)} />
1117
+ <ToggleField label="Supports Tools" checked={values.supportsTools} onChange={(checked) => setField('supportsTools', checked)} />
1118
+ <ToggleField label="Supports Streaming" checked={values.supportsStreaming} onChange={(checked) => setField('supportsStreaming', checked)} />
1119
+ </div>
1120
+
1121
+ <div className="grid grid-cols-2 gap-3">
1122
+ <LabeledField label="API Key Override">
1123
+ <Input value={values.apiKey} onChange={(event) => setField('apiKey', event.target.value)} />
1124
+ </LabeledField>
1125
+ <LabeledField label="Base URL Override">
1126
+ <Input value={values.baseUrl} onChange={(event) => setField('baseUrl', event.target.value)} />
1127
+ </LabeledField>
1128
+ </div>
1129
+
1130
+ {!showAdvanced ? (
1131
+ <button
1132
+ type="button"
1133
+ onClick={() => setShowAdvanced(true)}
1134
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
1135
+ >
1136
+ Show advanced options (upstream override, aliases, rate limits…)
1137
+ </button>
1138
+ ) : (
1139
+ <details open className="rounded border border-border p-3">
1140
+ <summary className="cursor-pointer text-sm font-medium">Advanced Model Fields</summary>
1141
+ <div className="space-y-3 mt-3">
1142
+ <div className="grid grid-cols-2 gap-3">
1143
+ <ToggleField label="Insecure TLS" checked={values.insecureTls} onChange={(checked) => setField('insecureTls', checked)} />
1144
+ <LabeledField label="Upstream Model Override">
1145
+ <Input value={values.upstreamModel} onChange={(event) => setField('upstreamModel', event.target.value)} />
1146
+ </LabeledField>
1147
+ <LabeledField label="Aliases (comma-separated)">
1148
+ <Input value={values.aliases} onChange={(event) => setField('aliases', event.target.value)} />
1149
+ </LabeledField>
1150
+ <LabeledField label="Modalities (comma-separated)">
1151
+ <Input value={values.modalities} onChange={(event) => setField('modalities', event.target.value)} placeholder={MODALITY_OPTIONS.join(', ')} />
1152
+ </LabeledField>
1153
+ </div>
1154
+
1155
+ <div>
1156
+ <label className="block">
1157
+ <span className="text-xs font-mono uppercase text-muted-foreground">Rate Limits</span>
1158
+ </label>
1159
+ <div className="mt-2">
1160
+ <Button
1161
+ variant="outline"
1162
+ size="sm"
1163
+ onClick={() => setField('rateLimitRules', [...values.rateLimitRules, { type: 'requests' as const, value: '', unit: 'minute' as const }])}
1164
+ className="mb-2"
1165
+ >
1166
+ <Plus className="w-3.5 h-3.5 mr-1" />
1167
+ Add Rule
1168
+ </Button>
1169
+ {values.rateLimitRules.length > 0 && (
1170
+ <div className="space-y-1.5">
1171
+ <div className="grid grid-cols-12 gap-2 text-2xs font-mono uppercase text-muted-foreground px-1">
1172
+ <div className="col-span-3">Type</div>
1173
+ <div className="col-span-3">Value</div>
1174
+ <div className="col-span-4">Window</div>
1175
+ <div className="col-span-2"></div>
1176
+ </div>
1177
+ {values.rateLimitRules.map((rule, idx) => (
1178
+ <div key={idx} className="grid grid-cols-12 gap-2 items-center">
1179
+ <div className="col-span-3">
1180
+ <select
1181
+ className="w-full h-8 rounded border border-input bg-transparent px-2 text-xs"
1182
+ value={rule.type}
1183
+ onChange={(e) => {
1184
+ const rules = [...values.rateLimitRules]
1185
+ rules[idx] = { ...rule, type: e.target.value as 'requests' | 'tokens' }
1186
+ setField('rateLimitRules', rules)
1187
+ }}
1188
+ >
1189
+ <option value="requests">Requests</option>
1190
+ <option value="tokens">Tokens</option>
1191
+ </select>
1192
+ </div>
1193
+ <div className="col-span-3">
1194
+ <Input
1195
+ className="h-8 text-xs"
1196
+ type="number"
1197
+ value={rule.value}
1198
+ onChange={(e) => {
1199
+ const rules = [...values.rateLimitRules]
1200
+ rules[idx] = { ...rule, value: e.target.value }
1201
+ setField('rateLimitRules', rules)
1202
+ }}
1203
+ placeholder="0"
1204
+ />
1205
+ </div>
1206
+ <div className="col-span-4">
1207
+ <select
1208
+ className="w-full h-8 rounded border border-input bg-transparent px-2 text-xs"
1209
+ value={rule.unit}
1210
+ onChange={(e) => {
1211
+ const rules = [...values.rateLimitRules]
1212
+ rules[idx] = { ...rule, unit: e.target.value as RateLimitRule['unit'] }
1213
+ setField('rateLimitRules', rules)
1214
+ }}
1215
+ >
1216
+ <option value="minute">per minute</option>
1217
+ <option value="hour">per hour</option>
1218
+ <option value="day">per day</option>
1219
+ <option value="week">per week</option>
1220
+ </select>
1221
+ </div>
1222
+ <div className="col-span-2 flex justify-end">
1223
+ <button
1224
+ type="button"
1225
+ onClick={() => {
1226
+ const rules = values.rateLimitRules.filter((_, i) => i !== idx)
1227
+ setField('rateLimitRules', rules)
1228
+ }}
1229
+ className="text-muted-foreground hover:text-destructive transition-colors"
1230
+ >
1231
+ <X className="w-3.5 h-3.5" />
1232
+ </button>
1233
+ </div>
1234
+ </div>
1235
+ ))}
1236
+ </div>
1237
+ )}
1238
+ </div>
1239
+ </div>
1240
+
1241
+ <LabeledField label="Limits (JSON, overrides above)">
1242
+ <Textarea value={values.limitsText} onChange={(event) => setField('limitsText', event.target.value)} rows={4} />
1243
+ </LabeledField>
1244
+ </div>
1245
+ </details>
1246
+ )}
1247
+
1248
+ <div className="flex justify-end gap-2">
1249
+ <Button variant="outline" onClick={props.onClose} disabled={saving}>Cancel</Button>
1250
+ <Button
1251
+ onClick={async () => {
1252
+ setSaving(true)
1253
+ setError(null)
1254
+ try {
1255
+ await props.onSubmit(values)
1256
+ } catch (err) {
1257
+ setError(getErrorMessage(err, 'Failed to save model'))
1258
+ } finally {
1259
+ setSaving(false)
1260
+ }
1261
+ }}
1262
+ disabled={saving}
1263
+ >
1264
+ Save Model
1265
+ </Button>
1266
+ </div>
1267
+ </div>
1268
+ </Overlay>
1269
+ )
1270
+ }
1271
+
1272
+ function Overlay(props: { title: string; children: React.ReactNode; onClose: () => void }) {
1273
+ return (
1274
+ <div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-start justify-center p-6 overflow-auto">
1275
+ <div className="w-full max-w-3xl rounded-lg border border-border bg-background shadow-lg">
1276
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border">
1277
+ <h3 className="font-mono font-semibold text-sm uppercase tracking-wider">{props.title}</h3>
1278
+ <button onClick={props.onClose} className="text-muted-foreground hover:text-foreground">
1279
+ <X className="w-4 h-4" />
1280
+ </button>
1281
+ </div>
1282
+ <div className="p-4">{props.children}</div>
1283
+ </div>
1284
+ </div>
1285
+ )
1286
+ }
1287
+
1288
+ function LabeledField(props: { label: string; description?: React.ReactNode; children: React.ReactNode }) {
1289
+ return (
1290
+ <label className="block space-y-1">
1291
+ <span className="text-xs font-mono uppercase text-muted-foreground">{props.label}</span>
1292
+ {props.description && <span className="block text-xs text-muted-foreground">{props.description}</span>}
1293
+ {props.children}
1294
+ </label>
1295
+ )
1296
+ }
1297
+
1298
+ function ToggleField(props: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
1299
+ return (
1300
+ <label className="flex items-center gap-2 rounded border border-border px-3 py-2 text-sm">
1301
+ <input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
1302
+ <span>{props.label}</span>
1303
+ </label>
1304
+ )
1305
+ }
1306
+
1307
+ function CommandRow({ command, description }: { command: string; description: string }) {
1308
+ return (
1309
+ <div className="flex items-center gap-4">
1310
+ <code className="font-mono text-sm bg-input px-2 py-1 rounded flex-shrink-0">
1311
+ {command}
1312
+ </code>
1313
+ <span className="text-sm text-muted-foreground">{description}</span>
1314
+ </div>
1315
+ )
1316
+ }
1317
+
1318
+ function emptyProviderForm(): ProviderFormValues {
1319
+ return {
1320
+ id: '',
1321
+ name: '',
1322
+ baseUrl: '',
1323
+ protocol: 'openai',
1324
+ enabled: true,
1325
+ supportsRouting: true,
1326
+ apiKey: '',
1327
+ description: '',
1328
+ docs: '',
1329
+ insecureTls: false,
1330
+ autoInsecureTlsDomains: '',
1331
+ envVar: '',
1332
+ authType: 'bearer',
1333
+ keyParam: '',
1334
+ headerName: '',
1335
+ keyPrefix: '',
1336
+ protocolConfigText: '',
1337
+ limitsText: '',
1338
+ }
1339
+ }
1340
+
1341
+ function providerToForm(provider: Provider): ProviderFormValues {
1342
+ return {
1343
+ id: provider.id,
1344
+ name: provider.name,
1345
+ baseUrl: provider.baseUrl,
1346
+ protocol: provider.protocol,
1347
+ enabled: provider.enabled,
1348
+ supportsRouting: provider.supportsRouting,
1349
+ apiKey: provider.apiKey ?? '',
1350
+ description: provider.description ?? '',
1351
+ docs: provider.docs ?? '',
1352
+ insecureTls: provider.insecureTls === true,
1353
+ autoInsecureTlsDomains: (provider.autoInsecureTlsDomains ?? []).join(', '),
1354
+ envVar: provider.envVar ?? '',
1355
+ authType: provider.auth?.type ?? 'none',
1356
+ keyParam: provider.auth?.keyParam ?? '',
1357
+ headerName: provider.auth?.headerName ?? '',
1358
+ keyPrefix: provider.auth?.keyPrefix ?? '',
1359
+ protocolConfigText: provider.protocolConfig ? JSON.stringify(provider.protocolConfig, null, 2) : '',
1360
+ limitsText: provider.limits ? JSON.stringify(provider.limits, null, 2) : '',
1361
+ }
1362
+ }
1363
+
1364
+ function emptyModelForm(providerId: string): ModelFormValues {
1365
+ return {
1366
+ providerId,
1367
+ modelId: '',
1368
+ upstreamModel: '',
1369
+ endpointType: 'llm',
1370
+ enabled: true,
1371
+ baseUrl: '',
1372
+ apiKey: '',
1373
+ insecureTls: false,
1374
+ aliases: '',
1375
+ modalities: 'text',
1376
+ inputCapabilities: 'text',
1377
+ outputCapabilities: 'text',
1378
+ supportsTools: false,
1379
+ supportsStreaming: false,
1380
+ limitsText: '',
1381
+ rateLimitRules: [],
1382
+ }
1383
+ }
1384
+
1385
+ function modelToForm(provider: Provider, model: ProviderModel): ModelFormValues {
1386
+ const rateLimitRules: RateLimitRule[] = []
1387
+ if (model.limits) {
1388
+ const unitMap: Record<string, 'minute' | 'hour' | 'day' | 'week'> = {
1389
+ perMinute: 'minute', perHour: 'hour', perDay: 'day', perWeek: 'week', perMonth: 'day',
1390
+ }
1391
+ for (const [key, val] of Object.entries(model.limits.requests ?? {})) {
1392
+ if (typeof val === 'number') {
1393
+ rateLimitRules.push({ type: 'requests', value: String(val), unit: unitMap[key] ?? 'minute' })
1394
+ }
1395
+ }
1396
+ for (const [key, val] of Object.entries(model.limits.tokens ?? {})) {
1397
+ if (typeof val === 'number') {
1398
+ rateLimitRules.push({ type: 'tokens', value: String(val), unit: unitMap[key] ?? 'minute' })
1399
+ }
1400
+ }
1401
+ }
1402
+ return {
1403
+ providerId: provider.id,
1404
+ modelId: model.modelId,
1405
+ upstreamModel: model.upstreamModel,
1406
+ endpointType: model.endpointType,
1407
+ enabled: model.enabled !== false,
1408
+ baseUrl: model.baseUrl ?? '',
1409
+ apiKey: model.apiKey ?? '',
1410
+ insecureTls: model.insecureTls === true,
1411
+ aliases: (model.aliases ?? []).join(', '),
1412
+ modalities: (model.modalities ?? []).join(', '),
1413
+ inputCapabilities: (model.capabilities.input ?? []).join(', '),
1414
+ outputCapabilities: (model.capabilities.output ?? []).join(', '),
1415
+ supportsTools: model.capabilities.supportsTools === true,
1416
+ supportsStreaming: model.capabilities.supportsStreaming === true,
1417
+ limitsText: model.limits ? JSON.stringify(model.limits, null, 2) : '',
1418
+ rateLimitRules,
1419
+ }
1420
+ }
1421
+
1422
+ function parseProviderForm(values: ProviderFormValues) {
1423
+ if (!values.id.trim() || !values.baseUrl.trim() || !values.protocol.trim()) {
1424
+ throw new Error('Provider ID, base URL, and protocol are required')
1425
+ }
1426
+ const protocolConfig = values.protocolConfigText.trim() ? parseJson(values.protocolConfigText, 'provider protocol config') : undefined
1427
+ const limits = values.limitsText.trim() ? parseJson(values.limitsText, 'provider limits') : undefined
1428
+ return {
1429
+ id: values.id.trim(),
1430
+ name: values.name.trim() || values.id.trim(),
1431
+ baseUrl: values.baseUrl.trim(),
1432
+ protocol: values.protocol.trim(),
1433
+ enabled: values.enabled,
1434
+ supportsRouting: values.supportsRouting,
1435
+ apiKey: values.apiKey.trim() || undefined,
1436
+ description: values.description.trim() || undefined,
1437
+ docs: values.docs.trim() || undefined,
1438
+ insecureTls: values.insecureTls || undefined,
1439
+ autoInsecureTlsDomains: parseCommaList(values.autoInsecureTlsDomains),
1440
+ envVar: values.envVar.trim() || undefined,
1441
+ auth: values.authType === 'none'
1442
+ ? undefined
1443
+ : {
1444
+ type: values.authType,
1445
+ keyParam: values.keyParam.trim() || undefined,
1446
+ headerName: values.headerName.trim() || undefined,
1447
+ keyPrefix: values.keyPrefix.trim() || undefined,
1448
+ },
1449
+ protocolConfig,
1450
+ limits,
1451
+ }
1452
+ }
1453
+
1454
+ function parseModelForm(values: ModelFormValues) {
1455
+ if (!values.modelId.trim() || !values.upstreamModel.trim()) {
1456
+ throw new Error('Model ID and upstream model are required')
1457
+ }
1458
+ const input = parseModalities(values.inputCapabilities, 'input capabilities')
1459
+ const output = parseModalities(values.outputCapabilities, 'output capabilities')
1460
+ const modalities = parseModalities(values.modalities, 'modalities')
1461
+ const limitsFromText = values.limitsText.trim() ? parseJson(values.limitsText, 'model limits') : undefined
1462
+ const unitMap: Record<string, string> = {
1463
+ minute: 'perMinute', hour: 'perHour', day: 'perDay', week: 'perWeek',
1464
+ }
1465
+ const limits: Record<string, unknown> = limitsFromText ?? {}
1466
+ const rules = values.rateLimitRules.filter((r) => r.value.trim())
1467
+ if (rules.length > 0) {
1468
+ const requestLimits: Record<string, number> = {}
1469
+ const tokenLimits: Record<string, number> = {}
1470
+ for (const rule of rules) {
1471
+ const key = unitMap[rule.unit]
1472
+ if (key) {
1473
+ if (rule.type === 'requests') {
1474
+ requestLimits[key] = Number(rule.value)
1475
+ } else {
1476
+ tokenLimits[key] = Number(rule.value)
1477
+ }
1478
+ }
1479
+ }
1480
+ if (!limits.requests) limits.requests = {}
1481
+ if (!limits.tokens) limits.tokens = {}
1482
+ Object.assign(limits.requests as Record<string, unknown>, requestLimits)
1483
+ Object.assign(limits.tokens as Record<string, unknown>, tokenLimits)
1484
+ }
1485
+ return {
1486
+ modelId: values.modelId.trim(),
1487
+ upstreamModel: values.upstreamModel.trim(),
1488
+ endpointType: values.endpointType,
1489
+ enabled: values.enabled,
1490
+ baseUrl: values.baseUrl.trim(),
1491
+ apiKey: values.apiKey.trim() || undefined,
1492
+ insecureTls: values.insecureTls || undefined,
1493
+ aliases: parseCommaList(values.aliases),
1494
+ modalities,
1495
+ capabilities: {
1496
+ input,
1497
+ output,
1498
+ supportsTools: values.supportsTools || undefined,
1499
+ supportsStreaming: values.supportsStreaming || undefined,
1500
+ },
1501
+ limits: Object.keys(limits).length > 0 ? limits : undefined,
1502
+ }
1503
+ }
1504
+
1505
+ function parseCommaList(value: string): string[] {
1506
+ return value.split(',').map((part) => part.trim()).filter(Boolean)
1507
+ }
1508
+
1509
+ function parseModalities(value: string, label: string): ModelModality[] {
1510
+ const parsed = parseCommaList(value)
1511
+ const invalid = parsed.filter((part) => !MODALITY_OPTIONS.includes(part as ModelModality))
1512
+ if (invalid.length > 0) {
1513
+ throw new Error(`Invalid ${label}: ${invalid.join(', ')}`)
1514
+ }
1515
+ return parsed as ModelModality[]
1516
+ }
1517
+
1518
+ function parseJson(value: string, label: string) {
1519
+ try {
1520
+ return JSON.parse(value)
1521
+ } catch {
1522
+ throw new Error(`Invalid ${label} JSON`)
1523
+ }
1524
+ }
1525
+
1526
+ function getErrorMessage(error: unknown, fallback: string): string {
1527
+ if (error && typeof error === 'object' && 'body' in error) {
1528
+ const body = (error as { body?: { error?: { message?: string } } }).body
1529
+ if (body?.error?.message) return body.error.message
1530
+ }
1531
+ if (error instanceof Error && error.message) return error.message
1532
+ return fallback
1533
+ }
1534
+
1535
+ function mergeModalities(capabilities: { input: ModelModality[]; output: ModelModality[] }): ModelModality[] {
1536
+ return Array.from(new Set([...capabilities.input, ...capabilities.output]))
1537
+ }
1538
+
1539
+ function inferEndpointType(output: ModelModality[]): EndpointType {
1540
+ if (output.includes('text')) return 'llm'
1541
+ if (output.includes('embedding')) return 'embedding'
1542
+ if (output.includes('image')) return 'diffusion'
1543
+ if (output.includes('audio')) return 'audio'
1544
+ return 'llm'
1545
+ }
1546
+
1547
+ function formatDiscoveryCapabilities(model: DiscoveredProviderModel): string {
1548
+ if (!model.capabilities) {
1549
+ return 'No capability metadata returned'
1550
+ }
1551
+ const parts = [
1552
+ model.capabilities.input.length > 0 ? `input ${model.capabilities.input.join(', ')}` : null,
1553
+ model.capabilities.output.length > 0 ? `output ${model.capabilities.output.join(', ')}` : null,
1554
+ model.capabilities.supportsTools === true ? 'tools' : null,
1555
+ model.capabilities.supportsStreaming === true ? 'streaming' : null,
1556
+ ].filter(Boolean)
1557
+ return parts.join(' • ') || 'No capability metadata returned'
1558
+ }
1559
+
1560
+ // ─────────────────────────────────────────────────────────────────────────────
1561
+ // Virtual Models Panel
1562
+ // ─────────────────────────────────────────────────────────────────────────────
1563
+
1564
+ function VirtualModelsPanel(props: {
1565
+ virtualModels: VirtualModel[]
1566
+ providers: Provider[]
1567
+ onReload: () => void
1568
+ onCreate: () => void
1569
+ onEdit: (vm: VirtualModel) => void
1570
+ }) {
1571
+ const [busy, setBusy] = useState<string | null>(null)
1572
+
1573
+ const run = async (key: string, fn: () => Promise<void>) => {
1574
+ setBusy(key)
1575
+ try { await fn(); props.onReload() }
1576
+ catch { /* errors handled by parent */ }
1577
+ finally { setBusy(null) }
1578
+ }
1579
+
1580
+ return (
1581
+ <div className="panel">
1582
+ <div className="panel-header">
1583
+ <Layers className="w-4 h-4 text-muted-foreground" />
1584
+ <span className="panel-title">Virtual Models</span>
1585
+ <span className="text-2xs text-muted-foreground ml-auto">{props.virtualModels.length} virtual models</span>
1586
+ <Button size="sm" variant="outline" onClick={props.onCreate}>
1587
+ <Plus className="w-3.5 h-3.5 mr-1" />
1588
+ Create Virtual Model
1589
+ </Button>
1590
+ </div>
1591
+ <div className="p-4 space-y-3">
1592
+ <p className="text-xs text-muted-foreground">
1593
+ Virtual models act as a single model backed by one or more real models. Useful for A/B testing and aggregating usage across providers.
1594
+ </p>
1595
+ {props.virtualModels.length === 0 && (
1596
+ <div className="rounded border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
1597
+ No virtual models configured.
1598
+ </div>
1599
+ )}
1600
+ {props.virtualModels.map((vm) => {
1601
+ const candidateNames = vm.candidates.map((c) => `${c.providerId}/${c.modelId}`)
1602
+ return (
1603
+ <div key={vm.id} className="rounded-lg border border-border overflow-hidden">
1604
+ <div className="px-4 py-3 flex items-start gap-3">
1605
+ <div className="flex-1 min-w-0">
1606
+ <div className="flex items-center gap-2">
1607
+ <div className={cn('status-dot', vm.enabled ? 'status-dot-live' : 'status-dot-down')} />
1608
+ <p className="font-medium text-sm">{vm.name}</p>
1609
+ <span className="text-2xs uppercase text-muted-foreground">{vm.id}</span>
1610
+ </div>
1611
+ <p className="text-2xs text-muted-foreground mt-1">
1612
+ {vm.candidates.length} candidate{vm.candidates.length !== 1 ? 's' : ''} · {vm.strategy.replace(/_/g, ' ')}
1613
+ </p>
1614
+ {candidateNames.length > 0 && (
1615
+ <p className="text-2xs text-muted-foreground mt-0.5 truncate font-mono">
1616
+ {candidateNames.join(', ')}
1617
+ </p>
1618
+ )}
1619
+ </div>
1620
+ <div className="flex items-center gap-2 shrink-0">
1621
+ <Button
1622
+ size="sm"
1623
+ variant="outline"
1624
+ disabled={busy === `toggle:${vm.id}`}
1625
+ onClick={() => void run(`toggle:${vm.id}`, async () => {
1626
+ await toggleVirtualModel(vm.id)
1627
+ })}
1628
+ >
1629
+ <Power className="w-3.5 h-3.5 mr-1" />
1630
+ {vm.enabled ? 'Disable' : 'Enable'}
1631
+ </Button>
1632
+ <Button size="sm" variant="outline" onClick={() => props.onEdit(vm)}>
1633
+ <Pencil className="w-3.5 h-3.5 mr-1" />
1634
+ Edit
1635
+ </Button>
1636
+ {vm.userDefined && (
1637
+ <Button
1638
+ size="sm"
1639
+ variant="outline"
1640
+ className="text-destructive hover:text-destructive"
1641
+ disabled={busy === `delete:${vm.id}`}
1642
+ onClick={() => {
1643
+ if (!window.confirm(`Delete virtual model ${vm.id}?`)) return
1644
+ void run(`delete:${vm.id}`, async () => {
1645
+ await deleteVirtualModel(vm.id)
1646
+ })
1647
+ }}
1648
+ >
1649
+ <Trash2 className="w-3.5 h-3.5 mr-1" />
1650
+ Delete
1651
+ </Button>
1652
+ )}
1653
+ </div>
1654
+ </div>
1655
+ </div>
1656
+ )
1657
+ })}
1658
+ </div>
1659
+ </div>
1660
+ )
1661
+ }
1662
+
1663
+ function VirtualModelFormDialog(props: {
1664
+ title: string
1665
+ initialValues: VmFormValues
1666
+ allProviderModels: Array<{ key: string; providerId: string; modelId: string; endpointType: EndpointType; modalities: string[]; capabilities: { input: string[]; output: string[] } }>
1667
+ isEdit: boolean
1668
+ onClose: () => void
1669
+ onSubmit: (values: VmFormValues) => Promise<void>
1670
+ }) {
1671
+ const [values, setValues] = useState<VmFormValues>(props.initialValues)
1672
+ const [error, setError] = useState<string | null>(null)
1673
+ const [saving, setSaving] = useState(false)
1674
+ const [filter, setFilter] = useState<string>('all')
1675
+ const [search, setSearch] = useState('')
1676
+
1677
+ const setField = <K extends keyof VmFormValues>(key: K, value: VmFormValues[K]) => {
1678
+ setValues((current) => ({ ...current, [key]: value }))
1679
+ }
1680
+
1681
+ const toggleCandidate = (key: string) => {
1682
+ const sel = new Set(values.candidateSelection)
1683
+ if (sel.has(key)) sel.delete(key)
1684
+ else sel.add(key)
1685
+ setField('candidateSelection', Array.from(sel))
1686
+ }
1687
+
1688
+ const filteredModels = props.allProviderModels.filter((m) => {
1689
+ if (filter !== 'all' && m.endpointType !== filter) return false
1690
+ if (search && !m.key.toLowerCase().includes(search.toLowerCase())) return false
1691
+ return true
1692
+ })
1693
+
1694
+ const groupedByProvider = new Map<string, typeof filteredModels>()
1695
+ for (const m of filteredModels) {
1696
+ const list = groupedByProvider.get(m.providerId) ?? []
1697
+ list.push(m)
1698
+ groupedByProvider.set(m.providerId, list)
1699
+ }
1700
+
1701
+ const selectedCount = values.candidateSelection.length
1702
+ const providerCount = new Set(values.candidateSelection.map((k) => k.split('/')[0])).size
1703
+
1704
+ return (
1705
+ <Overlay title={props.title} onClose={props.onClose}>
1706
+ <div className="space-y-4">
1707
+ {error && <div className="text-sm text-destructive">{error}</div>}
1708
+ <div className="grid grid-cols-2 gap-3">
1709
+ <LabeledField label="Virtual Model ID" description="Unique identifier used in API requests">
1710
+ <Input value={values.id} onChange={(event) => setField('id', event.target.value)} disabled={props.isEdit} />
1711
+ </LabeledField>
1712
+ <LabeledField label="Display Name" description="Friendly name shown in the UI">
1713
+ <Input value={values.name} onChange={(event) => setField('name', event.target.value)} />
1714
+ </LabeledField>
1715
+ </div>
1716
+ <LabeledField label="Strategy">
1717
+ <select className="w-full h-9 rounded-md border border-input bg-transparent px-3 text-sm" value={values.strategy} onChange={(event) => setField('strategy', event.target.value as VmFormValues['strategy'])}>
1718
+ <option value="highest_rank_available">Highest Rank Available</option>
1719
+ <option value="remaining_limit">Remaining Limit (failover on quota exhausted)</option>
1720
+ </select>
1721
+ </LabeledField>
1722
+
1723
+ <div>
1724
+ <label className="block">
1725
+ <span className="text-xs font-mono uppercase text-muted-foreground">Backend Models</span>
1726
+ </label>
1727
+ <div className="flex items-center gap-2 mt-2">
1728
+ <select className="h-8 rounded border border-input bg-transparent px-2 text-xs" value={filter} onChange={(e) => setFilter(e.target.value)}>
1729
+ <option value="all">All types</option>
1730
+ {ENDPOINT_OPTIONS.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
1731
+ </select>
1732
+ <Input className="h-8 text-sm" placeholder="Search models…" value={search} onChange={(e) => setSearch(e.target.value)} />
1733
+ </div>
1734
+ <div className="mt-2 space-y-2 max-h-64 overflow-auto pr-1">
1735
+ {Array.from(groupedByProvider.entries()).map(([providerId, models]) => {
1736
+ const selectedInGroup = models.filter((m) => values.candidateSelection.includes(m.key)).length
1737
+ return (
1738
+ <div key={providerId} className="rounded border border-border">
1739
+ <div className="px-3 py-1.5 bg-secondary/50 text-xs font-medium text-muted-foreground">
1740
+ {providerId} ({selectedInGroup}/{models.length} selected)
1741
+ </div>
1742
+ {models.map((m) => {
1743
+ const isSelected = values.candidateSelection.includes(m.key)
1744
+ const inputStr = m.capabilities.input?.join('+') ?? '?'
1745
+ const outputStr = m.capabilities.output?.join('+') ?? '?'
1746
+ return (
1747
+ <button
1748
+ key={m.key}
1749
+ type="button"
1750
+ onClick={() => toggleCandidate(m.key)}
1751
+ className={cn(
1752
+ 'w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors border-t border-border/50',
1753
+ isSelected ? 'bg-primary/5' : 'hover:bg-secondary/30'
1754
+ )}
1755
+ >
1756
+ <input type="checkbox" checked={isSelected} readOnly className="shrink-0" />
1757
+ <span className="font-mono flex-1 truncate">{m.modelId}</span>
1758
+ <span className="text-2xs uppercase text-muted-foreground shrink-0">{m.endpointType}</span>
1759
+ <span className="text-2xs text-muted-foreground shrink-0">{inputStr} → {outputStr}</span>
1760
+ </button>
1761
+ )
1762
+ })}
1763
+ </div>
1764
+ )
1765
+ })}
1766
+ </div>
1767
+ {selectedCount > 0 && (
1768
+ <p className="text-xs text-muted-foreground mt-2">
1769
+ {selectedCount} model{selectedCount !== 1 ? 's' : ''} selected across {providerCount} provider{providerCount !== 1 ? 's' : ''}
1770
+ </p>
1771
+ )}
1772
+ </div>
1773
+
1774
+ <div className="flex justify-end gap-2">
1775
+ <Button variant="outline" onClick={props.onClose} disabled={saving}>Cancel</Button>
1776
+ <Button
1777
+ onClick={async () => {
1778
+ setSaving(true)
1779
+ setError(null)
1780
+ try {
1781
+ await props.onSubmit(values)
1782
+ } catch (err) {
1783
+ setError(getErrorMessage(err, 'Failed to save virtual model'))
1784
+ } finally {
1785
+ setSaving(false)
1786
+ }
1787
+ }}
1788
+ disabled={saving}
1789
+ >
1790
+ {props.isEdit ? 'Update' : 'Create'} Virtual Model
1791
+ </Button>
1792
+ </div>
1793
+ </div>
1794
+ </Overlay>
1795
+ )
1796
+ }
1797
+
1798
+ type VmFormValues = {
1799
+ id: string
1800
+ name: string
1801
+ strategy: 'highest_rank_available' | 'remaining_limit'
1802
+ candidateSelection: string[]
1803
+ }
1804
+
1805
+ function emptyVmForm(): VmFormValues {
1806
+ return {
1807
+ id: '',
1808
+ name: '',
1809
+ strategy: 'highest_rank_available',
1810
+ candidateSelection: [],
1811
+ }
1812
+ }
1813
+
1814
+ function vmToForm(vm: VirtualModel): VmFormValues {
1815
+ return {
1816
+ id: vm.id,
1817
+ name: vm.name,
1818
+ strategy: vm.strategy,
1819
+ candidateSelection: vm.candidateSelection ?? [],
1820
+ }
1821
+ }
1822
+
1823
+ function parseVmForm(values: VmFormValues) {
1824
+ if (!values.id.trim()) {
1825
+ throw new Error('Virtual Model ID is required')
1826
+ }
1827
+ return {
1828
+ id: values.id.trim(),
1829
+ name: values.name.trim() || values.id.trim(),
1830
+ strategy: values.strategy,
1831
+ candidateSelection: values.candidateSelection,
1832
+ }
1833
+ }
1834
+
1835
+ // ─────────────────────────────────────────────────────────────────────────────
1836
+ // MCP Servers Panel
1837
+ // ─────────────────────────────────────────────────────────────────────────────
1838
+
1839
+ function McpServersPanel() {
1840
+ const [servers, setServers] = useState<McpServer[]>([])
1841
+ const [loading, setLoading] = useState(true)
1842
+ const [error, setError] = useState<string | null>(null)
1843
+ const [busy, setBusy] = useState<string | null>(null)
1844
+ const [showAdd, setShowAdd] = useState(false)
1845
+ const [newName, setNewName] = useState('')
1846
+ const [newUrl, setNewUrl] = useState('')
1847
+
1848
+ const load = useCallback(async () => {
1849
+ setLoading(true)
1850
+ setError(null)
1851
+ try {
1852
+ const res = await listMcpServers()
1853
+ setServers(res.data)
1854
+ } catch (err) {
1855
+ setError(getErrorMessage(err, 'Failed to load MCP servers'))
1856
+ } finally {
1857
+ setLoading(false)
1858
+ }
1859
+ }, [])
1860
+
1861
+ useEffect(() => { void load() }, [load])
1862
+
1863
+ const run = async (key: string, fn: () => Promise<void>) => {
1864
+ setBusy(key)
1865
+ setError(null)
1866
+ try { await fn(); await load() }
1867
+ catch (err) { setError(getErrorMessage(err, 'Operation failed')) }
1868
+ finally { setBusy(null) }
1869
+ }
1870
+
1871
+ const handleAdd = async () => {
1872
+ if (!newName.trim() || !newUrl.trim()) return
1873
+ await run('add', async () => {
1874
+ await addMcpServer(newName.trim(), newUrl.trim(), true)
1875
+ setNewName('')
1876
+ setNewUrl('')
1877
+ setShowAdd(false)
1878
+ })
1879
+ }
1880
+
1881
+ const statusDot = (status: McpServer['status']) => cn(
1882
+ 'w-2 h-2 rounded-full flex-shrink-0',
1883
+ status === 'connected' ? 'bg-green-500' : status === 'error' ? 'bg-red-500' : 'bg-muted-foreground'
1884
+ )
1885
+
1886
+ return (
1887
+ <div className="panel">
1888
+ <div className="panel-header flex items-center justify-between">
1889
+ <div className="flex items-center gap-2">
1890
+ <Plug className="w-3.5 h-3.5 text-primary" />
1891
+ <span className="panel-title">MCP Servers</span>
1892
+ </div>
1893
+ <div className="flex items-center gap-2">
1894
+ <Button variant="ghost" size="sm" onClick={load} disabled={loading} className="h-7 px-2">
1895
+ <RefreshCw className={cn('w-3 h-3', loading && 'animate-spin')} />
1896
+ </Button>
1897
+ <Button variant="outline" size="sm" onClick={() => setShowAdd((v) => !v)} className="h-7 px-2 gap-1">
1898
+ <Plus className="w-3 h-3" />
1899
+ Add
1900
+ </Button>
1901
+ </div>
1902
+ </div>
1903
+
1904
+ {error && (
1905
+ <div className="mx-4 mt-3 p-2 rounded bg-destructive/10 border border-destructive/30 text-xs text-destructive">
1906
+ {error}
1907
+ </div>
1908
+ )}
1909
+
1910
+ {showAdd && (
1911
+ <div className="p-4 border-b border-border space-y-3">
1912
+ <p className="text-xs font-mono uppercase text-muted-foreground">Register new MCP server</p>
1913
+ <div className="grid grid-cols-2 gap-2">
1914
+ <Input
1915
+ placeholder="Name (e.g. my-tools)"
1916
+ value={newName}
1917
+ onChange={(e) => setNewName(e.target.value)}
1918
+ className="h-8 text-sm font-mono"
1919
+ />
1920
+ <Input
1921
+ placeholder="URL (e.g. http://localhost:3100)"
1922
+ value={newUrl}
1923
+ onChange={(e) => setNewUrl(e.target.value)}
1924
+ className="h-8 text-sm font-mono"
1925
+ />
1926
+ </div>
1927
+ <div className="flex gap-2">
1928
+ <Button size="sm" onClick={handleAdd} disabled={busy === 'add' || !newName.trim() || !newUrl.trim()} className="h-7">
1929
+ Register
1930
+ </Button>
1931
+ <Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setNewName(''); setNewUrl('') }} className="h-7">
1932
+ Cancel
1933
+ </Button>
1934
+ </div>
1935
+ </div>
1936
+ )}
1937
+
1938
+ <div className="divide-y divide-border">
1939
+ {loading && servers.length === 0 && (
1940
+ <div className="p-4 text-sm text-muted-foreground">Loading...</div>
1941
+ )}
1942
+ {!loading && servers.length === 0 && (
1943
+ <div className="p-4 space-y-2">
1944
+ <p className="text-sm text-muted-foreground">No MCP servers registered.</p>
1945
+ <p className="text-xs text-muted-foreground font-mono">
1946
+ Use <code className="bg-input px-1 rounded">waypoi mcp add --name &lt;name&gt; --url &lt;url&gt;</code> or click Add above.
1947
+ </p>
1948
+ </div>
1949
+ )}
1950
+ {servers.map((server) => (
1951
+ <div key={server.id} className="p-4 flex items-start gap-3">
1952
+ <div className={statusDot(server.status)} style={{ marginTop: 4 }} />
1953
+ <div className="flex-1 min-w-0">
1954
+ <div className="flex items-center gap-2">
1955
+ <p className="font-mono text-sm font-medium truncate">{server.name}</p>
1956
+ {server.toolCount !== undefined && server.toolCount > 0 && (
1957
+ <span className="text-2xs font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
1958
+ {server.toolCount} tools
1959
+ </span>
1960
+ )}
1961
+ <span className={cn(
1962
+ 'text-2xs font-mono px-1.5 py-0.5 rounded',
1963
+ server.enabled ? 'bg-green-500/10 text-green-400' : 'bg-muted text-muted-foreground'
1964
+ )}>
1965
+ {server.enabled ? 'enabled' : 'disabled'}
1966
+ </span>
1967
+ </div>
1968
+ <p className="text-xs text-muted-foreground font-mono truncate">{server.url}</p>
1969
+ {server.lastError && (
1970
+ <p className="text-xs text-destructive mt-0.5 truncate">{server.lastError}</p>
1971
+ )}
1972
+ </div>
1973
+ <div className="flex items-center gap-1 shrink-0">
1974
+ <Button
1975
+ variant="ghost" size="sm"
1976
+ className="h-7 px-2 text-xs"
1977
+ disabled={busy === `connect:${server.id}`}
1978
+ onClick={() => run(`connect:${server.id}`, () => connectMcpServer(server.id).then(() => {}))}
1979
+ title="Re-connect and discover tools"
1980
+ >
1981
+ <RefreshCw className={cn('w-3 h-3', busy === `connect:${server.id}` && 'animate-spin')} />
1982
+ </Button>
1983
+ <Button
1984
+ variant="ghost" size="sm"
1985
+ className="h-7 px-2 text-xs"
1986
+ disabled={busy === `toggle:${server.id}`}
1987
+ onClick={() => run(`toggle:${server.id}`, () =>
1988
+ updateMcpServer(server.id, { enabled: !server.enabled }).then(() => {})
1989
+ )}
1990
+ title={server.enabled ? 'Disable' : 'Enable'}
1991
+ >
1992
+ <Power className={cn('w-3 h-3', server.enabled && 'text-green-400')} />
1993
+ </Button>
1994
+ <Button
1995
+ variant="ghost" size="sm"
1996
+ className="h-7 px-2 text-xs text-destructive hover:text-destructive"
1997
+ disabled={busy === `delete:${server.id}`}
1998
+ onClick={() => {
1999
+ if (confirm(`Remove MCP server "${server.name}"?`)) {
2000
+ void run(`delete:${server.id}`, () => deleteMcpServer(server.id))
2001
+ }
2002
+ }}
2003
+ title="Remove"
2004
+ >
2005
+ <Trash2 className="w-3 h-3" />
2006
+ </Button>
2007
+ </div>
2008
+ </div>
2009
+ ))}
2010
+ </div>
2011
+ </div>
2012
+ )
2013
+ }