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.
- package/.github/instructions/ui.instructions.md +42 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +71 -0
- package/.github/workflows/release.yml +48 -0
- package/.playwright-mcp/console-2026-04-04T01-41-10-746Z.log +2 -0
- package/.playwright-mcp/console-2026-04-04T01-41-28-799Z.log +3 -0
- package/.playwright-mcp/console-2026-04-05T02-26-51-909Z.log +76 -0
- package/.playwright-mcp/page-2026-04-04T01-41-10-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-04T01-41-29-141Z.yml +77 -0
- package/.playwright-mcp/page-2026-04-04T01-41-42-633Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T01-42-03-929Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-12-54-813Z.yml +6 -0
- package/.playwright-mcp/page-2026-04-04T02-14-58-600Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-03-923Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-07-426Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-15-25-729Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-16-22-984Z.yml +262 -0
- package/.playwright-mcp/page-2026-04-04T02-17-00-599Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-04T02-17-50-874Z.yml +190 -0
- package/.playwright-mcp/page-2026-04-05T02-26-55-570Z.yml +6 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +131 -0
- package/README.md +552 -0
- package/assets/agent-mode.png +0 -0
- package/assets/categorize.png +0 -0
- package/assets/dashboard.png +0 -0
- package/assets/endpoint-proxy.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/mcp-generate-image.png +0 -0
- package/assets/mcp-understand-image.png +0 -0
- package/assets/peek-token-flow.png +0 -0
- package/assets/playground.png +0 -0
- package/assets/sankey.png +0 -0
- package/cli/index.ts +2805 -0
- package/cli/legacyRewrite.ts +108 -0
- package/cli/modelRef.ts +24 -0
- package/dist/cli/index.js +2536 -0
- package/dist/cli/legacyRewrite.js +92 -0
- package/dist/cli/modelRef.js +20 -0
- package/dist/src/benchmark/artifacts.js +131 -0
- package/dist/src/benchmark/capabilityClassifier.js +81 -0
- package/dist/src/benchmark/capabilityStore.js +144 -0
- package/dist/src/benchmark/config.js +238 -0
- package/dist/src/benchmark/gates.js +118 -0
- package/dist/src/benchmark/jobs.js +252 -0
- package/dist/src/benchmark/runner.js +1847 -0
- package/dist/src/benchmark/schema.js +353 -0
- package/dist/src/benchmark/suites.js +314 -0
- package/dist/src/benchmark/tinyQaDataset.js +422 -0
- package/dist/src/benchmark/types.js +25 -0
- package/dist/src/config.js +47 -0
- package/dist/src/index.js +178 -0
- package/dist/src/mcp/client.js +215 -0
- package/dist/src/mcp/discovery.js +226 -0
- package/dist/src/mcp/policy.js +65 -0
- package/dist/src/mcp/registry.js +129 -0
- package/dist/src/mcp/service.js +460 -0
- package/dist/src/middleware/auth.js +179 -0
- package/dist/src/middleware/requestCapture.js +192 -0
- package/dist/src/middleware/requestStats.js +118 -0
- package/dist/src/pools/builder.js +132 -0
- package/dist/src/pools/repository.js +69 -0
- package/dist/src/pools/scheduler.js +360 -0
- package/dist/src/pools/types.js +2 -0
- package/dist/src/protocols/adapters/dashscope.js +267 -0
- package/dist/src/protocols/adapters/inferenceV2.js +346 -0
- package/dist/src/protocols/adapters/openai.js +27 -0
- package/dist/src/protocols/registry.js +99 -0
- package/dist/src/protocols/types.js +2 -0
- package/dist/src/providers/health.js +153 -0
- package/dist/src/providers/importer.js +289 -0
- package/dist/src/providers/modelRegistry.js +313 -0
- package/dist/src/providers/repository.js +361 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/routes/admin.js +531 -0
- package/dist/src/routes/audio.js +295 -0
- package/dist/src/routes/chat.js +240 -0
- package/dist/src/routes/embeddings.js +157 -0
- package/dist/src/routes/images.js +288 -0
- package/dist/src/routes/mcp.js +256 -0
- package/dist/src/routes/mcpService.js +100 -0
- package/dist/src/routes/models.js +48 -0
- package/dist/src/routes/responses.js +711 -0
- package/dist/src/routes/sessions.js +450 -0
- package/dist/src/routes/stats.js +270 -0
- package/dist/src/routes/ui.js +97 -0
- package/dist/src/routes/videos.js +107 -0
- package/dist/src/routing/router.js +338 -0
- package/dist/src/services/imageGeneration.js +280 -0
- package/dist/src/services/imageUnderstanding.js +352 -0
- package/dist/src/services/videoGeneration.js +79 -0
- package/dist/src/storage/captureRepository.js +1591 -0
- package/dist/src/storage/files.js +157 -0
- package/dist/src/storage/imageCache.js +346 -0
- package/dist/src/storage/repositories.js +388 -0
- package/dist/src/storage/sessionRepository.js +370 -0
- package/dist/src/storage/statsRepository.js +204 -0
- package/dist/src/transport/httpClient.js +126 -0
- package/dist/src/types.js +2 -0
- package/dist/src/utils/messageMedia.js +285 -0
- package/dist/src/utils/modelCapabilities.js +108 -0
- package/dist/src/utils/modelDiscovery.js +170 -0
- package/dist/src/version.js +5 -0
- package/dist/src/workers/captureRetention.js +25 -0
- package/dist/src/workers/configWatcher.js +91 -0
- package/dist/src/workers/healthChecker.js +21 -0
- package/dist/src/workers/statsRotation.js +41 -0
- package/docs/LLM/output_schema.md +312 -0
- package/docs/benchmark.md +208 -0
- package/docs/mcp-guidelines.md +125 -0
- package/docs/mcp-service.md +178 -0
- package/docs/opencode.md +86 -0
- package/docs/providers.md +79 -0
- package/examples/benchmark.config.yaml +28 -0
- package/examples/providers/alibaba-dashscope.yaml +88 -0
- package/examples/providers/alibaba-llm.yaml +64 -0
- package/examples/providers/alibaba-registry.yaml +7 -0
- package/examples/providers/inference-v2-ray.yaml +29 -0
- package/examples/scenarios/assets/omni-call-sample.wav +0 -0
- package/examples/scenarios/custom.jsonl +5 -0
- package/examples/scenarios/custom.yaml +40 -0
- package/model-form-v2.png +0 -0
- package/package.json +66 -0
- package/provider-form-v2.png +0 -0
- package/provider-form.png +0 -0
- package/scripts/manual-test.sh +11 -0
- package/scripts/version-from-git.js +23 -0
- package/src/benchmark/artifacts.ts +149 -0
- package/src/benchmark/capabilityClassifier.ts +99 -0
- package/src/benchmark/capabilityStore.ts +174 -0
- package/src/benchmark/config.ts +337 -0
- package/src/benchmark/gates.ts +164 -0
- package/src/benchmark/jobs.ts +312 -0
- package/src/benchmark/runner.ts +2519 -0
- package/src/benchmark/schema.ts +443 -0
- package/src/benchmark/suites.ts +323 -0
- package/src/benchmark/tinyQaDataset.ts +428 -0
- package/src/benchmark/types.ts +442 -0
- package/src/config.ts +44 -0
- package/src/index.ts +195 -0
- package/src/mcp/client.ts +305 -0
- package/src/mcp/discovery.ts +266 -0
- package/src/mcp/policy.ts +105 -0
- package/src/mcp/registry.ts +164 -0
- package/src/mcp/service.ts +611 -0
- package/src/middleware/auth.ts +251 -0
- package/src/middleware/requestCapture.ts +245 -0
- package/src/middleware/requestStats.ts +163 -0
- package/src/pools/builder.ts +159 -0
- package/src/pools/repository.ts +71 -0
- package/src/pools/scheduler.ts +425 -0
- package/src/pools/types.ts +117 -0
- package/src/protocols/adapters/dashscope.ts +335 -0
- package/src/protocols/adapters/inferenceV2.ts +428 -0
- package/src/protocols/adapters/openai.ts +32 -0
- package/src/protocols/registry.ts +117 -0
- package/src/protocols/types.ts +81 -0
- package/src/providers/health.ts +207 -0
- package/src/providers/importer.ts +402 -0
- package/src/providers/modelRegistry.ts +415 -0
- package/src/providers/repository.ts +439 -0
- package/src/providers/types.ts +113 -0
- package/src/routes/admin.ts +666 -0
- package/src/routes/audio.ts +372 -0
- package/src/routes/chat.ts +301 -0
- package/src/routes/embeddings.ts +197 -0
- package/src/routes/images.ts +356 -0
- package/src/routes/mcp.ts +320 -0
- package/src/routes/mcpService.ts +114 -0
- package/src/routes/models.ts +50 -0
- package/src/routes/responses.ts +872 -0
- package/src/routes/sessions.ts +558 -0
- package/src/routes/stats.ts +312 -0
- package/src/routes/ui.ts +96 -0
- package/src/routes/videos.ts +132 -0
- package/src/routing/router.ts +501 -0
- package/src/services/imageGeneration.ts +396 -0
- package/src/services/imageUnderstanding.ts +449 -0
- package/src/services/videoGeneration.ts +127 -0
- package/src/storage/captureRepository.ts +1835 -0
- package/src/storage/files.ts +178 -0
- package/src/storage/imageCache.ts +405 -0
- package/src/storage/repositories.ts +494 -0
- package/src/storage/sessionRepository.ts +419 -0
- package/src/storage/statsRepository.ts +238 -0
- package/src/transport/httpClient.ts +145 -0
- package/src/types.ts +322 -0
- package/src/utils/messageMedia.ts +293 -0
- package/src/utils/modelCapabilities.ts +161 -0
- package/src/utils/modelDiscovery.ts +203 -0
- package/src/workers/captureRetention.ts +25 -0
- package/src/workers/configWatcher.ts +115 -0
- package/src/workers/healthChecker.ts +22 -0
- package/src/workers/statsRotation.ts +49 -0
- package/tests/benchmarkAdminRoutes.test.ts +82 -0
- package/tests/benchmarkBasics.test.ts +116 -0
- package/tests/captureAdminRoutes.test.ts +420 -0
- package/tests/captureRepository.test.ts +797 -0
- package/tests/cliLegacyRewrite.test.ts +45 -0
- package/tests/imageGeneration.service.test.ts +107 -0
- package/tests/imageUnderstanding.service.test.ts +123 -0
- package/tests/mcpPolicy.test.ts +105 -0
- package/tests/mcpService.test.ts +1245 -0
- package/tests/modelRef.test.ts +23 -0
- package/tests/modelsRoutes.test.ts +154 -0
- package/tests/sessionMediaCache.test.ts +167 -0
- package/tests/statsRoutes.test.ts +323 -0
- package/tsconfig.json +15 -0
- package/ui/index.html +16 -0
- package/ui/package-lock.json +8521 -0
- package/ui/package.json +52 -0
- package/ui/postcss.config.js +6 -0
- package/ui/public/assets/apple-touch-icon.png +0 -0
- package/ui/public/assets/favicon-16.png +0 -0
- package/ui/public/assets/favicon-32.png +0 -0
- package/ui/public/assets/icon-192.png +0 -0
- package/ui/public/assets/icon-512.png +0 -0
- package/ui/src/App.tsx +27 -0
- package/ui/src/api/client.ts +1503 -0
- package/ui/src/components/EndpointUsageGuide.tsx +361 -0
- package/ui/src/components/Layout.tsx +124 -0
- package/ui/src/components/MessageContent.tsx +365 -0
- package/ui/src/components/ToolCallMessage.tsx +179 -0
- package/ui/src/components/ToolPicker.tsx +442 -0
- package/ui/src/components/messageContentParser.test.ts +41 -0
- package/ui/src/components/messageContentParser.ts +73 -0
- package/ui/src/components/thinkingPreview.test.ts +27 -0
- package/ui/src/components/thinkingPreview.ts +15 -0
- package/ui/src/components/toMermaidSankey.test.ts +78 -0
- package/ui/src/components/toMermaidSankey.ts +56 -0
- package/ui/src/components/ui/button.tsx +58 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/textarea.tsx +21 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +9 -0
- package/ui/src/pages/AgentPlayground.tsx +2010 -0
- package/ui/src/pages/Benchmark.tsx +988 -0
- package/ui/src/pages/Dashboard.tsx +581 -0
- package/ui/src/pages/Peek.tsx +962 -0
- package/ui/src/pages/Settings.tsx +2013 -0
- package/ui/src/pages/agentPlaygroundPayload.test.ts +109 -0
- package/ui/src/pages/agentPlaygroundPayload.ts +97 -0
- package/ui/src/pages/agentThinkingContent.test.ts +50 -0
- package/ui/src/pages/agentThinkingContent.ts +57 -0
- package/ui/src/pages/dashboardTokenUsage.test.ts +66 -0
- package/ui/src/pages/dashboardTokenUsage.ts +36 -0
- package/ui/src/pages/imageUpload.test.ts +39 -0
- package/ui/src/pages/imageUpload.ts +71 -0
- package/ui/src/pages/peekFilters.test.ts +29 -0
- package/ui/src/pages/peekFilters.ts +13 -0
- package/ui/src/pages/peekMedia.test.ts +58 -0
- package/ui/src/pages/peekMedia.ts +148 -0
- package/ui/src/pages/sessionAutoTitle.test.ts +128 -0
- package/ui/src/pages/sessionAutoTitle.ts +106 -0
- package/ui/src/stores/settings.ts +58 -0
- package/ui/src/styles/globals.css +223 -0
- package/ui/src/vite-env.d.ts +8 -0
- package/ui/tailwind.config.js +106 -0
- package/ui/tsconfig.json +32 -0
- 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 <name> --url <url></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
|
+
}
|