shinmentakezo07 commited on
Commit
ee2cd20
·
1 Parent(s): af75bef

feat: surface saved endpoint models and revamp the admin UI

Browse files

Model ids saved per OpenAI-compatible endpoint on the admin page are now
included in the admin model options and provider status, so numbered
routes (openai_compatible_1/<model>) stay listed even when the endpoint's
live /models route is unreachable.

- settings: OpenAI-compatible instances persist an optional models list
- status: endpoint cards report saved model ids alongside live ones
- admin API: model options merge configured, discovered, and stored ids
- tests: stored endpoint models appear in options/status; config round-trip

Admin UI overhaul:
- dark mode toggle (system-aware, persisted) with a light/dark token pass
- responsive sidebar: off-canvas drawer with backdrop/Escape on mobile
- sticky blurred header (view icon, title, description, version) and a
sticky action bar with status icons and a dirty-count badge
- config sections become cards with per-section icons and a model refresh
action; fields gain source badges and description hints
- provider/connected-account cards gain status dots and per-kind icons
- loading skeletons replace the bare loading text
- new primitives: tooltip, separator, skeleton; tw-animate-css animations
- rebuild admin static assets with content-hashed bundles
- version 4.23.2 -> 4.25.0

AGENTS.md CHANGED
@@ -96,6 +96,16 @@ When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new ca
96
 
97
  Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
98
 
 
 
 
 
 
 
 
 
 
 
99
  ## SUMMARY STANDARDS
100
 
101
  - Summaries must be technical and granular.
 
96
 
97
  Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
98
 
99
+ ## COMMIT & PUSH POLICY
100
+
101
+ - Never commit or push automatically. Only commit or push when the user
102
+ explicitly asks you to ("commit", "push", "commit and push", or similar).
103
+ - When work is finished and the user has not asked to commit or push, stop
104
+ and ask. Leave the working tree and staged state exactly as they are.
105
+ - Do not stage, amend, or rewrite commits as a side effect of other work.
106
+ - When the user does ask, review the diff first (`git status`, `git diff`)
107
+ and report what will be committed before committing.
108
+
109
  ## SUMMARY STANDARDS
110
 
111
  - Summaries must be technical and granular.
CLAUDE.md CHANGED
@@ -96,6 +96,16 @@ When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new ca
96
 
97
  Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
98
 
 
 
 
 
 
 
 
 
 
 
99
  ## SUMMARY STANDARDS
100
 
101
  - Summaries must be technical and granular.
 
96
 
97
  Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix.
98
 
99
+ ## COMMIT & PUSH POLICY
100
+
101
+ - Never commit or push automatically. Only commit or push when the user
102
+ explicitly asks you to ("commit", "push", "commit and push", or similar).
103
+ - When work is finished and the user has not asked to commit or push, stop
104
+ and ask. Leave the working tree and staged state exactly as they are.
105
+ - Do not stage, amend, or rewrite commits as a side effect of other work.
106
+ - When the user does ask, review the diff first (`git status`, `git diff`)
107
+ and report what will be committed before committing.
108
+
109
  ## SUMMARY STANDARDS
110
 
111
  - Summaries must be technical and granular.
README.md CHANGED
@@ -250,10 +250,11 @@ fcc-codex exec "hello"
250
  on, where the number is the endpoint's position in the list. Give each
251
  endpoint its own base URL, optional comma-separated key pool, and optional
252
  proxy, then use the numbered routes in `MODEL` so it is easy to see which
253
- endpoint a request uses. **Fetch models** pulls the endpoint's model ids
254
- from its `/models` route, lists them on the card, and makes them selectable
255
- in the Model Config dropdowns; "Use as default" sets the numbered route as
256
- the default model. Endpoint 1 also answers the legacy
 
257
  `openai_compatible/<model-id>` route; the single-endpoint env vars remain
258
  the fallback when no endpoints are configured.
259
  - Mistral Codestral uses a separate key from Mistral La Plateforme.
 
250
  on, where the number is the endpoint's position in the list. Give each
251
  endpoint its own base URL, optional comma-separated key pool, and optional
252
  proxy, then use the numbered routes in `MODEL` so it is easy to see which
253
+ endpoint a request uses. Add as many model ids as you want per endpoint
254
+ (comma-separated entries), or **Fetch models** to pull the endpoint's model
255
+ ids from its `/models` route; both list on the card and are selectable in
256
+ the Model Config dropdowns. "Use as default" sets the numbered route as the
257
+ default model. Endpoint 1 also answers the legacy
258
  `openai_compatible/<model-id>` route; the single-endpoint env vars remain
259
  the fallback when no endpoints are configured.
260
  - Mistral Codestral uses a separate key from Mistral La Plateforme.
frontend/package-lock.json CHANGED
@@ -11,13 +11,16 @@
11
  "@radix-ui/react-checkbox": "^1.1.2",
12
  "@radix-ui/react-label": "^2.1.1",
13
  "@radix-ui/react-select": "^2.1.2",
 
14
  "@radix-ui/react-slot": "^1.1.0",
 
15
  "class-variance-authority": "^0.7.1",
16
  "clsx": "^2.1.1",
17
  "lucide-react": "^0.469.0",
18
  "react": "^19.0.0",
19
  "react-dom": "^19.0.0",
20
- "tailwind-merge": "^2.6.0"
 
21
  },
22
  "devDependencies": {
23
  "@tailwindcss/vite": "^4.0.0",
@@ -1251,6 +1254,29 @@
1251
  }
1252
  }
1253
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1254
  "node_modules/@radix-ui/react-slot": {
1255
  "version": "1.3.3",
1256
  "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
@@ -1269,6 +1295,41 @@
1269
  }
1270
  }
1271
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1272
  "node_modules/@radix-ui/react-use-callback-ref": {
1273
  "version": "1.1.4",
1274
  "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
@@ -3137,6 +3198,15 @@
3137
  "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
3138
  "license": "0BSD"
3139
  },
 
 
 
 
 
 
 
 
 
3140
  "node_modules/typescript": {
3141
  "version": "5.7.3",
3142
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
 
11
  "@radix-ui/react-checkbox": "^1.1.2",
12
  "@radix-ui/react-label": "^2.1.1",
13
  "@radix-ui/react-select": "^2.1.2",
14
+ "@radix-ui/react-separator": "^1.1.15",
15
  "@radix-ui/react-slot": "^1.1.0",
16
+ "@radix-ui/react-tooltip": "^1.2.16",
17
  "class-variance-authority": "^0.7.1",
18
  "clsx": "^2.1.1",
19
  "lucide-react": "^0.469.0",
20
  "react": "^19.0.0",
21
  "react-dom": "^19.0.0",
22
+ "tailwind-merge": "^2.6.0",
23
+ "tw-animate-css": "^1.4.0"
24
  },
25
  "devDependencies": {
26
  "@tailwindcss/vite": "^4.0.0",
 
1254
  }
1255
  }
1256
  },
1257
+ "node_modules/@radix-ui/react-separator": {
1258
+ "version": "1.1.15",
1259
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz",
1260
+ "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==",
1261
+ "license": "MIT",
1262
+ "dependencies": {
1263
+ "@radix-ui/react-primitive": "2.1.10"
1264
+ },
1265
+ "peerDependencies": {
1266
+ "@types/react": "*",
1267
+ "@types/react-dom": "*",
1268
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1269
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1270
+ },
1271
+ "peerDependenciesMeta": {
1272
+ "@types/react": {
1273
+ "optional": true
1274
+ },
1275
+ "@types/react-dom": {
1276
+ "optional": true
1277
+ }
1278
+ }
1279
+ },
1280
  "node_modules/@radix-ui/react-slot": {
1281
  "version": "1.3.3",
1282
  "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
 
1295
  }
1296
  }
1297
  },
1298
+ "node_modules/@radix-ui/react-tooltip": {
1299
+ "version": "1.2.16",
1300
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz",
1301
+ "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==",
1302
+ "license": "MIT",
1303
+ "dependencies": {
1304
+ "@radix-ui/primitive": "1.1.7",
1305
+ "@radix-ui/react-compose-refs": "1.1.5",
1306
+ "@radix-ui/react-context": "1.2.2",
1307
+ "@radix-ui/react-dismissable-layer": "1.1.19",
1308
+ "@radix-ui/react-id": "1.1.4",
1309
+ "@radix-ui/react-popper": "1.3.7",
1310
+ "@radix-ui/react-portal": "1.1.17",
1311
+ "@radix-ui/react-presence": "1.1.10",
1312
+ "@radix-ui/react-primitive": "2.1.10",
1313
+ "@radix-ui/react-slot": "1.3.3",
1314
+ "@radix-ui/react-use-controllable-state": "1.2.6",
1315
+ "@radix-ui/react-use-layout-effect": "1.1.4",
1316
+ "@radix-ui/react-visually-hidden": "1.2.11"
1317
+ },
1318
+ "peerDependencies": {
1319
+ "@types/react": "*",
1320
+ "@types/react-dom": "*",
1321
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
1322
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
1323
+ },
1324
+ "peerDependenciesMeta": {
1325
+ "@types/react": {
1326
+ "optional": true
1327
+ },
1328
+ "@types/react-dom": {
1329
+ "optional": true
1330
+ }
1331
+ }
1332
+ },
1333
  "node_modules/@radix-ui/react-use-callback-ref": {
1334
  "version": "1.1.4",
1335
  "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
 
3198
  "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
3199
  "license": "0BSD"
3200
  },
3201
+ "node_modules/tw-animate-css": {
3202
+ "version": "1.4.0",
3203
+ "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
3204
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
3205
+ "license": "MIT",
3206
+ "funding": {
3207
+ "url": "https://github.com/sponsors/Wombosvideo"
3208
+ }
3209
+ },
3210
  "node_modules/typescript": {
3211
  "version": "5.7.3",
3212
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
frontend/package.json CHANGED
@@ -13,13 +13,16 @@
13
  "@radix-ui/react-checkbox": "^1.1.2",
14
  "@radix-ui/react-label": "^2.1.1",
15
  "@radix-ui/react-select": "^2.1.2",
 
16
  "@radix-ui/react-slot": "^1.1.0",
 
17
  "class-variance-authority": "^0.7.1",
18
  "clsx": "^2.1.1",
19
  "lucide-react": "^0.469.0",
20
  "react": "^19.0.0",
21
  "react-dom": "^19.0.0",
22
- "tailwind-merge": "^2.6.0"
 
23
  },
24
  "devDependencies": {
25
  "@tailwindcss/vite": "^4.0.0",
 
13
  "@radix-ui/react-checkbox": "^1.1.2",
14
  "@radix-ui/react-label": "^2.1.1",
15
  "@radix-ui/react-select": "^2.1.2",
16
+ "@radix-ui/react-separator": "^1.1.15",
17
  "@radix-ui/react-slot": "^1.1.0",
18
+ "@radix-ui/react-tooltip": "^1.2.16",
19
  "class-variance-authority": "^0.7.1",
20
  "clsx": "^2.1.1",
21
  "lucide-react": "^0.469.0",
22
  "react": "^19.0.0",
23
  "react-dom": "^19.0.0",
24
+ "tailwind-merge": "^2.6.0",
25
+ "tw-animate-css": "^1.4.0"
26
  },
27
  "devDependencies": {
28
  "@tailwindcss/vite": "^4.0.0",
frontend/src/App.tsx CHANGED
@@ -1,4 +1,13 @@
1
  import { useCallback, useEffect, useRef, useState } from "react"
 
 
 
 
 
 
 
 
 
2
 
3
  import {
4
  applyConfig,
@@ -24,6 +33,10 @@ import { ConfigView } from "./components/ConfigView"
24
  import { OpenAICompatibleView } from "./components/OpenAICompatibleView"
25
  import { ProvidersView } from "./components/ProvidersView"
26
  import { Sidebar } from "./components/Sidebar"
 
 
 
 
27
 
28
  export const MASKED_SECRET = "********"
29
 
@@ -31,8 +44,10 @@ interface ViewGroup {
31
  id: string
32
  label: string
33
  title: string
 
34
  path: string
35
  sections: string[]
 
36
  }
37
 
38
  export const VIEW_GROUPS: ViewGroup[] = [
@@ -40,29 +55,37 @@ export const VIEW_GROUPS: ViewGroup[] = [
40
  id: "providers",
41
  label: "Providers",
42
  title: "Providers",
 
43
  path: "providers",
44
  sections: ["providers", "runtime"],
 
45
  },
46
  {
47
  id: "model_config",
48
  label: "Model Config",
49
  title: "Model Config",
 
50
  path: "model-config",
51
  sections: ["models", "reasoning", "web_tools"],
 
52
  },
53
  {
54
  id: "openai_compatible",
55
  label: "OpenAI-Compatible",
56
  title: "OpenAI-Compatible Endpoints",
 
57
  path: "openai-compatible",
58
  sections: [],
 
59
  },
60
  {
61
  id: "messaging",
62
  label: "Messaging",
63
  title: "Messaging",
 
64
  path: "messaging",
65
  sections: ["messaging", "voice"],
 
66
  },
67
  ]
68
 
@@ -138,6 +161,7 @@ export default function App() {
138
  const [message, setMessage] = useState<{ text: string; kind?: string } | null>(null)
139
  const [activeView, setActiveView] = useState<string>(() => syncActiveView())
140
  const [applying, setApplying] = useState(false)
 
141
  const authTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
142
 
143
  const setMessageOk = useCallback((text: string) => setMessage({ text }), [])
@@ -286,8 +310,13 @@ export default function App() {
286
  void load()
287
  const onHashChange = () => setActiveView(viewFromPath(pathFromHash()))
288
  window.addEventListener("hashchange", onHashChange)
 
 
 
 
289
  return () => {
290
  window.removeEventListener("hashchange", onHashChange)
 
291
  for (const timer of Object.values(authTimers.current)) clearTimeout(timer)
292
  authTimers.current = {}
293
  }
@@ -412,6 +441,19 @@ export default function App() {
412
  [setMessageError],
413
  )
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  const handleRefreshModels = useCallback(
416
  async (_providerId: string, done?: () => void) => {
417
  try {
@@ -501,32 +543,109 @@ export default function App() {
501
 
502
  if (!config) {
503
  return (
504
- <div className="flex min-h-screen items-center justify-center">
505
- <p className="text-muted-foreground">Loading admin config...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  </div>
507
  )
508
  }
509
 
 
 
 
 
510
  return (
511
  <div className="flex min-h-screen">
512
  <Sidebar
513
  config={config}
514
  activeView={activeView}
515
  onSelectView={handleSelectView}
 
516
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  <main className="flex min-w-0 flex-1 flex-col">
518
- <header className="flex items-center justify-between border-b px-6 py-4">
519
- <h2 className="text-2xl font-semibold">
520
- {VIEW_GROUPS.find((view) => view.id === activeView)?.title ?? "Providers"}
521
- </h2>
522
- {config.version ? (
523
- <span className="rounded-full border border-input px-2.5 py-0.5 text-xs text-muted-foreground">
524
- v{config.version}
525
- </span>
526
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  </header>
528
 
529
- <div className="flex-1 space-y-8 p-6">
530
  {activeView === "providers" && (
531
  <ProvidersView
532
  config={config}
@@ -550,6 +669,7 @@ export default function App() {
550
  localStatus={localStatus}
551
  onTestProvider={handleTestProvider}
552
  onFetchModels={handleFetchEndpointModels}
 
553
  onMessage={showMessage}
554
  />
555
  )}
 
1
  import { useCallback, useEffect, useRef, useState } from "react"
2
+ import {
3
+ type LucideIcon,
4
+ Menu,
5
+ MessageSquare,
6
+ Plug,
7
+ Server,
8
+ SlidersHorizontal,
9
+ X,
10
+ } from "lucide-react"
11
 
12
  import {
13
  applyConfig,
 
33
  import { OpenAICompatibleView } from "./components/OpenAICompatibleView"
34
  import { ProvidersView } from "./components/ProvidersView"
35
  import { Sidebar } from "./components/Sidebar"
36
+ import { ThemeToggle } from "./components/ThemeToggle"
37
+ import { Badge } from "./components/ui/badge"
38
+ import { Button } from "./components/ui/button"
39
+ import { Skeleton } from "./components/ui/skeleton"
40
 
41
  export const MASKED_SECRET = "********"
42
 
 
44
  id: string
45
  label: string
46
  title: string
47
+ description: string
48
  path: string
49
  sections: string[]
50
+ icon: LucideIcon
51
  }
52
 
53
  export const VIEW_GROUPS: ViewGroup[] = [
 
55
  id: "providers",
56
  label: "Providers",
57
  title: "Providers",
58
+ description: "Check connectivity and manage provider credentials",
59
  path: "providers",
60
  sections: ["providers", "runtime"],
61
+ icon: Server,
62
  },
63
  {
64
  id: "model_config",
65
  label: "Model Config",
66
  title: "Model Config",
67
+ description: "Choose models, reasoning behavior, and web tooling",
68
  path: "model-config",
69
  sections: ["models", "reasoning", "web_tools"],
70
+ icon: SlidersHorizontal,
71
  },
72
  {
73
  id: "openai_compatible",
74
  label: "OpenAI-Compatible",
75
  title: "OpenAI-Compatible Endpoints",
76
+ description: "Add any OpenAI-compatible server as a numbered provider",
77
  path: "openai-compatible",
78
  sections: [],
79
+ icon: Plug,
80
  },
81
  {
82
  id: "messaging",
83
  label: "Messaging",
84
  title: "Messaging",
85
+ description: "Chat platforms and voice integration",
86
  path: "messaging",
87
  sections: ["messaging", "voice"],
88
+ icon: MessageSquare,
89
  },
90
  ]
91
 
 
161
  const [message, setMessage] = useState<{ text: string; kind?: string } | null>(null)
162
  const [activeView, setActiveView] = useState<string>(() => syncActiveView())
163
  const [applying, setApplying] = useState(false)
164
+ const [mobileNavOpen, setMobileNavOpen] = useState(false)
165
  const authTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
166
 
167
  const setMessageOk = useCallback((text: string) => setMessage({ text }), [])
 
310
  void load()
311
  const onHashChange = () => setActiveView(viewFromPath(pathFromHash()))
312
  window.addEventListener("hashchange", onHashChange)
313
+ const onKeyDown = (event: KeyboardEvent) => {
314
+ if (event.key === "Escape") setMobileNavOpen(false)
315
+ }
316
+ window.addEventListener("keydown", onKeyDown)
317
  return () => {
318
  window.removeEventListener("hashchange", onHashChange)
319
+ window.removeEventListener("keydown", onKeyDown)
320
  for (const timer of Object.values(authTimers.current)) clearTimeout(timer)
321
  authTimers.current = {}
322
  }
 
441
  [setMessageError],
442
  )
443
 
444
+ const handleAddEndpointModels = useCallback(
445
+ (providerId: string, models: string[]) => {
446
+ if (models.length === 0) return
447
+ setModelOptions((prev) => [
448
+ ...new Set([
449
+ ...prev,
450
+ ...models.map((model) => `${providerId}/${model}`),
451
+ ]),
452
+ ])
453
+ },
454
+ [],
455
+ )
456
+
457
  const handleRefreshModels = useCallback(
458
  async (_providerId: string, done?: () => void) => {
459
  try {
 
543
 
544
  if (!config) {
545
  return (
546
+ <div className="flex min-h-screen">
547
+ <aside className="hidden w-64 shrink-0 flex-col gap-3 border-r p-4 md:flex">
548
+ <Skeleton className="h-10 w-full" />
549
+ <div className="mt-3 space-y-2">
550
+ <Skeleton className="h-9 w-5/6" />
551
+ <Skeleton className="h-9 w-4/6" />
552
+ <Skeleton className="h-9 w-3/6" />
553
+ </div>
554
+ </aside>
555
+ <main className="flex min-w-0 flex-1 flex-col gap-6 p-4 md:p-6">
556
+ <div className="space-y-2">
557
+ <Skeleton className="h-7 w-56" />
558
+ <Skeleton className="h-4 w-72" />
559
+ </div>
560
+ <Skeleton className="h-36 w-full" />
561
+ <Skeleton className="h-36 w-full" />
562
+ <Skeleton className="h-36 w-full" />
563
+ </main>
564
  </div>
565
  )
566
  }
567
 
568
+ const activeGroup =
569
+ VIEW_GROUPS.find((view) => view.id === activeView) ?? VIEW_GROUPS[0]
570
+ const ActiveIcon = activeGroup.icon
571
+
572
  return (
573
  <div className="flex min-h-screen">
574
  <Sidebar
575
  config={config}
576
  activeView={activeView}
577
  onSelectView={handleSelectView}
578
+ className="hidden md:flex"
579
  />
580
+ {mobileNavOpen ? (
581
+ <div
582
+ className="fixed inset-0 z-50 md:hidden"
583
+ role="dialog"
584
+ aria-modal="true"
585
+ aria-label="Admin navigation"
586
+ >
587
+ <button
588
+ type="button"
589
+ aria-label="Close navigation"
590
+ className="absolute inset-0 bg-black/60"
591
+ onClick={() => setMobileNavOpen(false)}
592
+ />
593
+ <Sidebar
594
+ config={config}
595
+ activeView={activeView}
596
+ onSelectView={handleSelectView}
597
+ onNavigate={() => setMobileNavOpen(false)}
598
+ className="animate-in slide-in-from-left absolute inset-y-0 left-0 shadow-2xl"
599
+ />
600
+ <button
601
+ type="button"
602
+ aria-label="Close navigation"
603
+ className="absolute top-4 right-4 flex size-8 items-center justify-center rounded-md text-white/80 hover:bg-white/10 hover:text-white"
604
+ onClick={() => setMobileNavOpen(false)}
605
+ >
606
+ <X className="size-5" />
607
+ </button>
608
+ </div>
609
+ ) : null}
610
  <main className="flex min-w-0 flex-1 flex-col">
611
+ <header className="sticky top-0 z-30 flex items-center justify-between gap-4 border-b bg-background/80 px-4 py-3 backdrop-blur-md md:px-6">
612
+ <div className="flex min-w-0 items-center gap-3">
613
+ <Button
614
+ type="button"
615
+ variant="ghost"
616
+ size="icon"
617
+ className="md:hidden"
618
+ aria-label="Open navigation"
619
+ onClick={() => setMobileNavOpen(true)}
620
+ >
621
+ <Menu />
622
+ </Button>
623
+ <div className="hidden size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary md:flex">
624
+ <ActiveIcon className="size-4.5" />
625
+ </div>
626
+ <div className="min-w-0">
627
+ <h2 className="truncate text-lg font-semibold leading-tight">
628
+ {activeGroup.title}
629
+ </h2>
630
+ <p className="truncate text-xs text-muted-foreground">
631
+ {activeGroup.description}
632
+ </p>
633
+ </div>
634
+ </div>
635
+ <div className="flex shrink-0 items-center gap-1.5">
636
+ {config.version ? (
637
+ <Badge
638
+ variant="outline"
639
+ className="hidden font-mono text-muted-foreground sm:inline-flex"
640
+ >
641
+ v{config.version}
642
+ </Badge>
643
+ ) : null}
644
+ <ThemeToggle />
645
+ </div>
646
  </header>
647
 
648
+ <div className="flex-1 space-y-8 p-4 md:p-6">
649
  {activeView === "providers" && (
650
  <ProvidersView
651
  config={config}
 
669
  localStatus={localStatus}
670
  onTestProvider={handleTestProvider}
671
  onFetchModels={handleFetchEndpointModels}
672
+ onAddModels={handleAddEndpointModels}
673
  onMessage={showMessage}
674
  />
675
  )}
frontend/src/components/ActionBar.tsx CHANGED
@@ -1,4 +1,7 @@
 
 
1
  import { cn } from "@/lib/utils"
 
2
  import { Button } from "./ui/button"
3
 
4
  interface ActionBarProps {
@@ -10,6 +13,19 @@ interface ActionBarProps {
10
  onApply: () => void
11
  }
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export function ActionBar({
14
  configPath,
15
  dirtyCount,
@@ -19,31 +35,51 @@ export function ActionBar({
19
  onApply,
20
  }: ActionBarProps) {
21
  return (
22
- <footer className="flex items-center gap-4 border-t px-6 py-3">
23
- <div className="flex min-w-0 items-center gap-4">
24
- <strong className="shrink-0 text-sm">
25
- {dirtyCount === 0 ? "No changes" : `${dirtyCount} unsaved change${dirtyCount === 1 ? "" : "s"}`}
26
- </strong>
27
- <span className="truncate text-xs text-muted-foreground">{configPath}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </div>
29
  <div
30
  className={cn(
31
- "min-w-0 flex-1 truncate text-sm",
32
  message?.kind === "error" && "text-destructive",
33
  message?.kind === "warn" && "text-amber-600",
34
  message?.kind === "ok" && "text-emerald-600",
35
  )}
 
 
36
  >
37
- {message?.text ?? ""}
 
38
  </div>
39
  <div className="flex shrink-0 gap-2">
40
- <Button variant="secondary" onClick={onValidate}>
 
41
  Validate
42
  </Button>
43
  <Button onClick={onApply} disabled={dirtyCount === 0 || applying}>
 
44
  {applying ? "Applying..." : "Apply"}
45
  </Button>
46
  </div>
47
  </footer>
48
  )
49
- }
 
1
+ import { CheckCircle2, CircleAlert, Loader2, Play, ShieldCheck, TriangleAlert, XCircle } from "lucide-react"
2
+
3
  import { cn } from "@/lib/utils"
4
+ import { Badge } from "./ui/badge"
5
  import { Button } from "./ui/button"
6
 
7
  interface ActionBarProps {
 
13
  onApply: () => void
14
  }
15
 
16
+ function messageIcon(kind?: string) {
17
+ switch (kind) {
18
+ case "error":
19
+ return <XCircle className="size-4 shrink-0 text-destructive" />
20
+ case "warn":
21
+ return <TriangleAlert className="size-4 shrink-0 text-amber-600" />
22
+ case "ok":
23
+ return <CheckCircle2 className="size-4 shrink-0 text-emerald-600" />
24
+ default:
25
+ return null
26
+ }
27
+ }
28
+
29
  export function ActionBar({
30
  configPath,
31
  dirtyCount,
 
35
  onApply,
36
  }: ActionBarProps) {
37
  return (
38
+ <footer className="sticky bottom-0 z-30 flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-background/90 px-4 py-3 backdrop-blur-md md:px-6">
39
+ <div className="flex min-w-0 items-center gap-3">
40
+ <Badge
41
+ variant={dirtyCount === 0 ? "outline" : "warn"}
42
+ className={cn(
43
+ "shrink-0 gap-1.5",
44
+ dirtyCount === 0 && "text-muted-foreground",
45
+ )}
46
+ >
47
+ {dirtyCount === 0 ? (
48
+ <CheckCircle2 className="size-3.5 text-emerald-500" />
49
+ ) : (
50
+ <CircleAlert className="size-3.5" />
51
+ )}
52
+ {dirtyCount === 0
53
+ ? "No changes"
54
+ : `${dirtyCount} unsaved change${dirtyCount === 1 ? "" : "s"}`}
55
+ </Badge>
56
+ <span className="hidden min-w-0 truncate font-mono text-xs text-muted-foreground lg:inline">
57
+ {configPath}
58
+ </span>
59
  </div>
60
  <div
61
  className={cn(
62
+ "flex min-w-0 flex-1 items-center gap-2 text-sm",
63
  message?.kind === "error" && "text-destructive",
64
  message?.kind === "warn" && "text-amber-600",
65
  message?.kind === "ok" && "text-emerald-600",
66
  )}
67
+ role={message?.kind === "error" ? "alert" : undefined}
68
+ aria-live="polite"
69
  >
70
+ {messageIcon(message?.kind)}
71
+ <span className="truncate">{message?.text ?? ""}</span>
72
  </div>
73
  <div className="flex shrink-0 gap-2">
74
+ <Button variant="secondary" onClick={onValidate} disabled={applying}>
75
+ <ShieldCheck />
76
  Validate
77
  </Button>
78
  <Button onClick={onApply} disabled={dirtyCount === 0 || applying}>
79
+ {applying ? <Loader2 className="animate-spin" /> : <Play />}
80
  {applying ? "Applying..." : "Apply"}
81
  </Button>
82
  </div>
83
  </footer>
84
  )
85
+ }
frontend/src/components/ConfigView.tsx CHANGED
@@ -1,9 +1,31 @@
1
  import { useMemo, useState } from "react"
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import { VIEW_GROUPS } from "@/App"
4
  import type { AdminConfig, AdminSection } from "@/lib/types"
5
  import { FieldRow } from "./FieldControl"
6
  import { Button } from "./ui/button"
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  interface ConfigViewProps {
9
  config: AdminConfig
@@ -75,6 +97,7 @@ function SettingsSection({
75
  const [advancedOpen, setAdvancedOpen] = useState(false)
76
  const [refreshing, setRefreshing] = useState(false)
77
  const isModelSection = section.id === "models"
 
78
 
79
  const fieldRows = (showAdvanced: boolean) =>
80
  fields
@@ -91,41 +114,50 @@ function SettingsSection({
91
  ))
92
 
93
  return (
94
- <section className="space-y-4">
95
- <div className="flex items-center justify-between">
96
- <div>
97
- <h3 className="text-lg font-semibold">{section.label}</h3>
98
- {section.description ? (
99
- <p className="text-sm text-muted-foreground">{section.description}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ) : null}
101
  </div>
102
- {isModelSection ? (
 
103
  <Button
104
- variant="secondary"
 
105
  size="sm"
106
- disabled={refreshing}
107
- onClick={() => {
108
- setRefreshing(true)
109
- onRefreshModels("__refresh_models__", () => setRefreshing(false))
110
- }}
111
  >
112
- {refreshing ? "Refreshing" : "Refresh models"}
113
  </Button>
114
  ) : null}
115
- </div>
116
- <div className="grid gap-4">
117
- {fieldRows(advancedOpen)}
118
- </div>
119
- {hasAdvanced ? (
120
- <Button
121
- type="button"
122
- variant="ghost"
123
- size="sm"
124
- onClick={() => setAdvancedOpen((prev) => !prev)}
125
- >
126
- {advancedOpen ? "Hide advanced" : "Show advanced"}
127
- </Button>
128
- ) : null}
129
- </section>
130
  )
131
- }
 
1
  import { useMemo, useState } from "react"
2
+ import {
3
+ Brain,
4
+ Gauge,
5
+ Globe,
6
+ Lightbulb,
7
+ MessageSquare,
8
+ Mic,
9
+ RefreshCw,
10
+ Server,
11
+ type LucideIcon,
12
+ } from "lucide-react"
13
 
14
  import { VIEW_GROUPS } from "@/App"
15
  import type { AdminConfig, AdminSection } from "@/lib/types"
16
  import { FieldRow } from "./FieldControl"
17
  import { Button } from "./ui/button"
18
+ import { Card, CardContent } from "./ui/card"
19
+
20
+ const SECTION_ICONS: Record<string, LucideIcon> = {
21
+ providers: Server,
22
+ runtime: Gauge,
23
+ models: Brain,
24
+ reasoning: Lightbulb,
25
+ web_tools: Globe,
26
+ messaging: MessageSquare,
27
+ voice: Mic,
28
+ }
29
 
30
  interface ConfigViewProps {
31
  config: AdminConfig
 
97
  const [advancedOpen, setAdvancedOpen] = useState(false)
98
  const [refreshing, setRefreshing] = useState(false)
99
  const isModelSection = section.id === "models"
100
+ const SectionIcon = SECTION_ICONS[section.id] ?? Server
101
 
102
  const fieldRows = (showAdvanced: boolean) =>
103
  fields
 
114
  ))
115
 
116
  return (
117
+ <Card className="gap-0 overflow-hidden">
118
+ <CardContent className="flex flex-col gap-4 px-5 py-4 md:px-6">
119
+ <div className="flex flex-wrap items-center justify-between gap-3">
120
+ <div className="flex min-w-0 items-center gap-3">
121
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
122
+ <SectionIcon className="size-4.5" />
123
+ </span>
124
+ <div className="min-w-0">
125
+ <h3 className="text-base font-semibold leading-tight">
126
+ {section.label}
127
+ </h3>
128
+ {section.description ? (
129
+ <p className="text-sm text-muted-foreground">{section.description}</p>
130
+ ) : null}
131
+ </div>
132
+ </div>
133
+ {isModelSection ? (
134
+ <Button
135
+ variant="secondary"
136
+ size="sm"
137
+ disabled={refreshing}
138
+ onClick={() => {
139
+ setRefreshing(true)
140
+ onRefreshModels("__refresh_models__", () => setRefreshing(false))
141
+ }}
142
+ >
143
+ <RefreshCw className={refreshing ? "animate-spin" : undefined} />
144
+ {refreshing ? "Refreshing" : "Refresh models"}
145
+ </Button>
146
  ) : null}
147
  </div>
148
+ <div className="grid gap-3">{fieldRows(advancedOpen)}</div>
149
+ {hasAdvanced ? (
150
  <Button
151
+ type="button"
152
+ variant="ghost"
153
  size="sm"
154
+ className="self-start text-muted-foreground"
155
+ onClick={() => setAdvancedOpen((prev) => !prev)}
 
 
 
156
  >
157
+ {advancedOpen ? "Hide advanced" : "Show advanced"}
158
  </Button>
159
  ) : null}
160
+ </CardContent>
161
+ </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  )
163
+ }
frontend/src/components/FieldControl.tsx CHANGED
@@ -1,7 +1,9 @@
1
  import { useState } from "react"
 
2
 
3
  import type { AdminField } from "@/lib/types"
4
  import { cn } from "@/lib/utils"
 
5
  import { Checkbox } from "./ui/checkbox"
6
  import { Input } from "./ui/input"
7
  import { Label } from "./ui/label"
@@ -162,10 +164,11 @@ export function FieldRow({
162
  onValueChange,
163
  onMessage,
164
  }: FieldRowProps) {
 
165
  return (
166
  <div
167
  className={cn(
168
- "flex flex-col gap-2 rounded-lg border p-4",
169
  field.advanced && "advanced-field hidden",
170
  )}
171
  data-key={field.key}
@@ -174,7 +177,14 @@ export function FieldRow({
174
  <Label htmlFor={field.type === "boolean" ? undefined : `field-${field.key}`}>
175
  <span className="text-sm font-medium">{field.label}</span>
176
  </Label>
177
- <span className="text-xs text-muted-foreground">{sourceText(field)}</span>
 
 
 
 
 
 
 
178
  </div>
179
  <FieldControl
180
  field={field}
@@ -184,7 +194,10 @@ export function FieldRow({
184
  onMessage={onMessage}
185
  />
186
  {field.description ? (
187
- <p className="text-xs text-muted-foreground">{field.description}</p>
 
 
 
188
  ) : null}
189
  </div>
190
  )
 
1
  import { useState } from "react"
2
+ import { Info } from "lucide-react"
3
 
4
  import type { AdminField } from "@/lib/types"
5
  import { cn } from "@/lib/utils"
6
+ import { Badge } from "./ui/badge"
7
  import { Checkbox } from "./ui/checkbox"
8
  import { Input } from "./ui/input"
9
  import { Label } from "./ui/label"
 
164
  onValueChange,
165
  onMessage,
166
  }: FieldRowProps) {
167
+ const source = sourceText(field)
168
  return (
169
  <div
170
  className={cn(
171
+ "flex flex-col gap-2.5 rounded-lg border bg-card/40 p-4 transition-colors hover:border-border/80",
172
  field.advanced && "advanced-field hidden",
173
  )}
174
  data-key={field.key}
 
177
  <Label htmlFor={field.type === "boolean" ? undefined : `field-${field.key}`}>
178
  <span className="text-sm font-medium">{field.label}</span>
179
  </Label>
180
+ {source ? (
181
+ <Badge
182
+ variant="outline"
183
+ className="gap-1 px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
184
+ >
185
+ {source}
186
+ </Badge>
187
+ ) : null}
188
  </div>
189
  <FieldControl
190
  field={field}
 
194
  onMessage={onMessage}
195
  />
196
  {field.description ? (
197
+ <p className="flex items-start gap-1.5 text-xs text-muted-foreground">
198
+ <Info className="mt-0.5 size-3.5 shrink-0 opacity-70" />
199
+ <span>{field.description}</span>
200
+ </p>
201
  ) : null}
202
  </div>
203
  )
frontend/src/components/OpenAICompatibleView.tsx CHANGED
@@ -1,13 +1,29 @@
1
  import { useEffect, useState } from "react"
 
 
 
 
 
 
 
 
 
 
 
 
2
 
 
3
  import type {
4
  AdminConfig,
5
  LocalProviderStatus,
6
  OpenAICompatibleInstance,
7
  } from "@/lib/types"
 
 
8
  import { Badge } from "./ui/badge"
9
  import { Button } from "./ui/button"
10
  import { Card, CardContent } from "./ui/card"
 
11
  import { Input } from "./ui/input"
12
  import { Label } from "./ui/label"
13
 
@@ -20,6 +36,7 @@ interface OpenAICompatibleViewProps {
20
  localStatus: Record<string, LocalProviderStatus>
21
  onTestProvider: (providerId: string, done?: () => void) => void
22
  onFetchModels: (providerId: string) => Promise<string[]>
 
23
  onMessage: (text: string, kind?: string) => void
24
  }
25
 
@@ -70,14 +87,6 @@ function statusFor(
70
  }
71
  }
72
 
73
- function badgeVariant(status: string): "ok" | "warn" | "error" | "neutral" {
74
- if (["configured", "reachable"].includes(status)) return "ok"
75
- if (["missing_key", "missing_config", "missing_url", "unknown"].includes(status))
76
- return "warn"
77
- if (["offline", "error"].includes(status)) return "error"
78
- return "neutral"
79
- }
80
-
81
  interface InstanceCardProps {
82
  index: number
83
  instance: OpenAICompatibleInstance
@@ -86,11 +95,15 @@ interface InstanceCardProps {
86
  testing: boolean
87
  fetching: boolean
88
  models: string[]
 
 
89
  onPatch: (patch: Partial<OpenAICompatibleInstance>) => void
90
  onRemove: () => void
91
  onTest: () => void
92
  onFetch: () => void
93
  onClearModels: () => void
 
 
94
  onUseModel: (model: string) => void
95
  }
96
 
@@ -102,35 +115,63 @@ function InstanceCard({
102
  testing,
103
  fetching,
104
  models,
 
 
105
  onPatch,
106
  onRemove,
107
  onTest,
108
  onFetch,
109
  onClearModels,
 
 
110
  onUseModel,
111
  }: InstanceCardProps) {
112
  const providerId = providerIdFor(index)
113
  const canProbe = !locked && Boolean(instance.base_url.trim())
 
 
 
 
 
 
 
 
 
 
 
 
114
  return (
115
- <Card>
116
  <CardContent className="flex flex-col gap-4 py-4">
117
  <div className="flex flex-wrap items-center justify-between gap-2">
118
- <div className="flex items-center gap-2">
119
- <h4 className="text-base font-semibold">Endpoint {index + 1}</h4>
120
- <Badge variant="outline" className="font-mono">
121
- {providerId}/&lt;model&gt;
122
- </Badge>
 
 
 
 
 
 
 
123
  </div>
124
- <div className="flex items-center gap-2">
125
- <Badge variant={badgeVariant(status.status)}>{status.label}</Badge>
 
 
 
126
  <Button
127
  type="button"
128
  variant="ghost"
129
- size="sm"
130
  disabled={locked}
 
 
131
  onClick={onRemove}
132
  >
133
- Remove
134
  </Button>
135
  </div>
136
  </div>
@@ -174,12 +215,23 @@ function InstanceCard({
174
  onChange={(event) => onPatch({ proxy: event.target.value })}
175
  />
176
  </div>
177
- {models.length > 0 ? (
178
- <div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
179
- <div className="flex items-center justify-between gap-2">
180
- <p className="text-sm font-medium">
181
- {models.length} model{models.length === 1 ? "" : "s"} from /models
 
 
 
182
  </p>
 
 
 
 
 
 
 
 
183
  <Button
184
  type="button"
185
  variant="ghost"
@@ -187,34 +239,99 @@ function InstanceCard({
187
  disabled={locked}
188
  onClick={onClearModels}
189
  >
 
190
  Clear
191
  </Button>
192
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  <div className="grid max-h-56 gap-1 overflow-auto">
194
- {models.map((model) => (
195
- <div
196
- key={model}
197
- className="flex items-center justify-between gap-2 rounded-md bg-card px-2.5 py-1.5"
198
- >
199
- <span className="min-w-0 flex-1 truncate font-mono text-xs">
200
- {model}
201
- </span>
202
- <Button
203
- type="button"
204
- variant="ghost"
205
- size="sm"
206
- disabled={locked}
207
- onClick={() => onUseModel(model)}
208
  >
209
- Use as default
210
- </Button>
211
- </div>
212
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
- </div>
215
- ) : null}
216
- <div className="flex flex-wrap items-center justify-between gap-2">
217
- <p className="text-xs text-muted-foreground">
 
 
 
 
 
 
218
  {status.baseUrl ? `Resolves to ${status.baseUrl}` : "Not checked yet"}
219
  </p>
220
  <div className="flex items-center gap-2">
@@ -226,6 +343,11 @@ function InstanceCard({
226
  onClick={onFetch}
227
  title="Fetch model ids from GET {base}/models (uses the saved config, Apply first)"
228
  >
 
 
 
 
 
229
  {fetching ? "Fetching…" : "Fetch models"}
230
  </Button>
231
  <Button
@@ -235,6 +357,7 @@ function InstanceCard({
235
  disabled={!canProbe || testing}
236
  onClick={onTest}
237
  >
 
238
  {testing ? "Testing…" : "Test connection"}
239
  </Button>
240
  </div>
@@ -251,6 +374,7 @@ export function OpenAICompatibleView({
251
  localStatus,
252
  onTestProvider,
253
  onFetchModels,
 
254
  onMessage,
255
  }: OpenAICompatibleViewProps) {
256
  const field = config.fields.find(
@@ -297,19 +421,52 @@ export function OpenAICompatibleView({
297
  try {
298
  const models = await onFetchModels(providerId)
299
  if (models.length === 0) {
300
- setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
301
  return
302
  }
303
- setModelsByProvider((prev) => ({ ...prev, [providerId]: models }))
 
 
 
304
  onMessage(`Fetched ${models.length} models from ${providerId}.`, "ok")
305
  } finally {
306
  setFetchingId(null)
307
  }
308
  }
309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  const useModelAsDefault = (index: number, model: string) => {
311
  const route = `${providerIdFor(index)}/${model}`
312
  onValuesChange({ ...values, MODEL: route })
 
 
 
 
313
  onMessage(`Default model set to ${route}. Apply to save.`, "ok")
314
  }
315
 
@@ -323,20 +480,45 @@ export function OpenAICompatibleView({
323
 
324
  const legacyBaseUrl = values["OPENAI_COMPATIBLE_BASE_URL"] ?? ""
325
  const legacyKey = values["OPENAI_COMPATIBLE_API_KEY"] ?? ""
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  return (
328
  <section className="space-y-4">
329
  <div>
330
  <h3 className="text-lg font-semibold">Endpoints</h3>
331
- <p className="text-sm text-muted-foreground">
332
  Add any OpenAI-compatible server (vLLM, LM Studio, Ollama, Together, or
333
  your own gateway). Each endpoint becomes a numbered provider:{" "}
334
  <code>openai_compatible_1/&lt;model&gt;</code>,{" "}
335
- <code>openai_compatible_2/&lt;model&gt;</code>, … Use{" "}
336
- <strong>Fetch models</strong> to pull model ids from the endpoint's{" "}
337
- <code>/models</code> route, then Apply to save. Fetched ids also appear
338
- in the Model Config dropdowns.
 
 
339
  </p>
 
 
 
 
 
 
 
 
 
 
340
  </div>
341
 
342
  {instances.length === 0 && legacyBaseUrl.trim() ? (
@@ -354,6 +536,7 @@ export function OpenAICompatibleView({
354
  commit([{ base_url: legacyBaseUrl, api_keys: legacyKey, proxy: "" }])
355
  }
356
  >
 
357
  Import as endpoint 1
358
  </Button>
359
  </div>
@@ -361,11 +544,15 @@ export function OpenAICompatibleView({
361
 
362
  {instances.length === 0 ? (
363
  <Card className="border-dashed">
364
- <CardContent className="flex flex-col items-center gap-3 py-10 text-center">
 
 
 
365
  <p className="text-sm text-muted-foreground">
366
  No endpoints yet. Add your first OpenAI-compatible endpoint.
367
  </p>
368
  <Button type="button" onClick={addInstance}>
 
369
  Add endpoint
370
  </Button>
371
  </CardContent>
@@ -383,7 +570,9 @@ export function OpenAICompatibleView({
383
  locked={field.locked}
384
  testing={testingId === providerId}
385
  fetching={fetchingId === providerId}
386
- models={modelsByProvider[providerId] ?? []}
 
 
387
  onPatch={(patch) => updateInstance(index, patch)}
388
  onRemove={() => removeInstance(index)}
389
  onTest={() => {
@@ -391,18 +580,25 @@ export function OpenAICompatibleView({
391
  onTestProvider(providerId, () => setTestingId(null))
392
  }}
393
  onFetch={() => void fetchModels(index)}
394
- onClearModels={() =>
395
  setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
396
- }
 
 
 
397
  onUseModel={(model) => useModelAsDefault(index, model)}
398
  />
399
  )
400
  })}
401
- <div>
402
- <Button type="button" variant="secondary" onClick={addInstance}>
403
- Add endpoint
404
- </Button>
405
- </div>
 
 
 
 
406
  </div>
407
  )}
408
  </section>
 
1
  import { useEffect, useState } from "react"
2
+ import {
3
+ Cable,
4
+ Check,
5
+ ListChecks,
6
+ ListPlus,
7
+ Loader2,
8
+ Plus,
9
+ RefreshCw,
10
+ Server,
11
+ Star,
12
+ Trash2,
13
+ } from "lucide-react"
14
 
15
+ import { statusClass } from "@/App"
16
  import type {
17
  AdminConfig,
18
  LocalProviderStatus,
19
  OpenAICompatibleInstance,
20
  } from "@/lib/types"
21
+ import { statusDotClass } from "@/lib/status"
22
+ import { cn } from "@/lib/utils"
23
  import { Badge } from "./ui/badge"
24
  import { Button } from "./ui/button"
25
  import { Card, CardContent } from "./ui/card"
26
+ import { Checkbox } from "./ui/checkbox"
27
  import { Input } from "./ui/input"
28
  import { Label } from "./ui/label"
29
 
 
36
  localStatus: Record<string, LocalProviderStatus>
37
  onTestProvider: (providerId: string, done?: () => void) => void
38
  onFetchModels: (providerId: string) => Promise<string[]>
39
+ onAddModels: (providerId: string, models: string[]) => void
40
  onMessage: (text: string, kind?: string) => void
41
  }
42
 
 
87
  }
88
  }
89
 
 
 
 
 
 
 
 
 
90
  interface InstanceCardProps {
91
  index: number
92
  instance: OpenAICompatibleInstance
 
95
  testing: boolean
96
  fetching: boolean
97
  models: string[]
98
+ selected: string[]
99
+ currentDefault: string
100
  onPatch: (patch: Partial<OpenAICompatibleInstance>) => void
101
  onRemove: () => void
102
  onTest: () => void
103
  onFetch: () => void
104
  onClearModels: () => void
105
+ onAddModels: (models: string[]) => void
106
+ onToggleModel: (model: string) => void
107
  onUseModel: (model: string) => void
108
  }
109
 
 
115
  testing,
116
  fetching,
117
  models,
118
+ selected,
119
+ currentDefault,
120
  onPatch,
121
  onRemove,
122
  onTest,
123
  onFetch,
124
  onClearModels,
125
+ onAddModels,
126
+ onToggleModel,
127
  onUseModel,
128
  }: InstanceCardProps) {
129
  const providerId = providerIdFor(index)
130
  const canProbe = !locked && Boolean(instance.base_url.trim())
131
+ const [modelDraft, setModelDraft] = useState("")
132
+
133
+ const addFromDraft = () => {
134
+ const ids = modelDraft
135
+ .split(",")
136
+ .map((id) => id.trim())
137
+ .filter(Boolean)
138
+ if (ids.length === 0) return
139
+ onAddModels(ids)
140
+ setModelDraft("")
141
+ }
142
+
143
  return (
144
+ <Card className="overflow-hidden">
145
  <CardContent className="flex flex-col gap-4 py-4">
146
  <div className="flex flex-wrap items-center justify-between gap-2">
147
+ <div className="flex items-center gap-3">
148
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary ring-1 ring-primary/20">
149
+ {index + 1}
150
+ </span>
151
+ <div className="min-w-0">
152
+ <h4 className="text-base font-semibold leading-tight">
153
+ Endpoint {index + 1}
154
+ </h4>
155
+ <p className="font-mono text-xs text-muted-foreground">
156
+ {providerId}/&lt;model&gt;
157
+ </p>
158
+ </div>
159
  </div>
160
+ <div className="flex items-center gap-1.5">
161
+ <Badge variant={statusClass(status.status)} className="gap-1.5">
162
+ <span className={cn("size-1.5 rounded-full", statusDotClass(status.status))} />
163
+ {status.label}
164
+ </Badge>
165
  <Button
166
  type="button"
167
  variant="ghost"
168
+ size="icon"
169
  disabled={locked}
170
+ aria-label={`Remove endpoint ${index + 1}`}
171
+ title="Remove endpoint"
172
  onClick={onRemove}
173
  >
174
+ <Trash2 />
175
  </Button>
176
  </div>
177
  </div>
 
215
  onChange={(event) => onPatch({ proxy: event.target.value })}
216
  />
217
  </div>
218
+ <div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
219
+ <div className="flex items-center justify-between gap-2">
220
+ <div className="flex items-center gap-2">
221
+ <p className="flex items-center gap-1.5 text-sm font-medium">
222
+ <ListChecks className="size-4 text-muted-foreground" />
223
+ {models.length === 0
224
+ ? "No model ids yet"
225
+ : `${models.length} model id${models.length === 1 ? "" : "s"}`}
226
  </p>
227
+ {selected.length > 0 ? (
228
+ <Badge variant="outline" className="gap-1 px-1.5 py-0 text-[10px]">
229
+ <Check className="size-3" />
230
+ {selected.length} selected
231
+ </Badge>
232
+ ) : null}
233
+ </div>
234
+ {models.length > 0 ? (
235
  <Button
236
  type="button"
237
  variant="ghost"
 
239
  disabled={locked}
240
  onClick={onClearModels}
241
  >
242
+ <Trash2 />
243
  Clear
244
  </Button>
245
+ ) : null}
246
+ </div>
247
+ <div className="flex gap-2">
248
+ <Input
249
+ id={`${providerId}-model-id`}
250
+ type="text"
251
+ autoComplete="off"
252
+ placeholder="Add model id, comma-separated (gpt-4o, deepseek-v3, …)"
253
+ value={modelDraft}
254
+ disabled={locked}
255
+ onChange={(event) => setModelDraft(event.target.value)}
256
+ onKeyDown={(event) => {
257
+ if (event.key === "Enter") {
258
+ event.preventDefault()
259
+ addFromDraft()
260
+ }
261
+ }}
262
+ />
263
+ <Button
264
+ type="button"
265
+ variant="secondary"
266
+ size="sm"
267
+ disabled={locked || !modelDraft.trim()}
268
+ onClick={addFromDraft}
269
+ >
270
+ <ListPlus />
271
+ Add
272
+ </Button>
273
+ </div>
274
+ {models.length > 0 ? (
275
  <div className="grid max-h-56 gap-1 overflow-auto">
276
+ {models.map((model) => {
277
+ const isDefault = `${providerId}/${model}` === currentDefault
278
+ return (
279
+ <div
280
+ key={model}
281
+ className={cn(
282
+ "flex items-center gap-2 rounded-md border px-2.5 py-1.5 transition-colors hover:border-border",
283
+ isDefault
284
+ ? "border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10"
285
+ : "border-transparent bg-card",
286
+ )}
 
 
 
287
  >
288
+ <Checkbox
289
+ id={`${providerId}-model-${model}`}
290
+ checked={selected.includes(model)}
291
+ disabled={locked}
292
+ aria-label={`Apply model ${model}`}
293
+ title={
294
+ selected.includes(model)
295
+ ? "Applied to this endpoint"
296
+ : "Select to apply this model"
297
+ }
298
+ onCheckedChange={() => onToggleModel(model)}
299
+ />
300
+ <span className="flex min-w-0 flex-1 items-center gap-1.5">
301
+ {isDefault ? (
302
+ <Star className="size-3.5 shrink-0 fill-amber-400 text-amber-500" />
303
+ ) : null}
304
+ <span className="truncate font-mono text-xs">{model}</span>
305
+ {isDefault ? (
306
+ <Badge variant="ok" className="px-1.5 py-0 text-[10px]">
307
+ default
308
+ </Badge>
309
+ ) : null}
310
+ </span>
311
+ <Button
312
+ type="button"
313
+ variant="ghost"
314
+ size="sm"
315
+ disabled={locked}
316
+ onClick={() => onUseModel(model)}
317
+ >
318
+ <Star className="size-3.5" />
319
+ Use as default
320
+ </Button>
321
+ </div>
322
+ )
323
+ })}
324
  </div>
325
+ ) : (
326
+ <p className="text-xs text-muted-foreground">
327
+ Add model ids manually, or use <strong>Fetch models</strong> below
328
+ to pull them from GET {`{base}`}/models.
329
+ </p>
330
+ )}
331
+ </div>
332
+ <div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/30 px-3 py-2">
333
+ <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
334
+ <Cable className="size-3.5" />
335
  {status.baseUrl ? `Resolves to ${status.baseUrl}` : "Not checked yet"}
336
  </p>
337
  <div className="flex items-center gap-2">
 
343
  onClick={onFetch}
344
  title="Fetch model ids from GET {base}/models (uses the saved config, Apply first)"
345
  >
346
+ {fetching ? (
347
+ <Loader2 className="animate-spin" />
348
+ ) : (
349
+ <RefreshCw />
350
+ )}
351
  {fetching ? "Fetching…" : "Fetch models"}
352
  </Button>
353
  <Button
 
357
  disabled={!canProbe || testing}
358
  onClick={onTest}
359
  >
360
+ {testing ? <Loader2 className="animate-spin" /> : <Cable />}
361
  {testing ? "Testing…" : "Test connection"}
362
  </Button>
363
  </div>
 
374
  localStatus,
375
  onTestProvider,
376
  onFetchModels,
377
+ onAddModels,
378
  onMessage,
379
  }: OpenAICompatibleViewProps) {
380
  const field = config.fields.find(
 
421
  try {
422
  const models = await onFetchModels(providerId)
423
  if (models.length === 0) {
424
+ onMessage(`No models found at ${providerId}.`, "warn")
425
  return
426
  }
427
+ setModelsByProvider((prev) => ({
428
+ ...prev,
429
+ [providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
430
+ }))
431
  onMessage(`Fetched ${models.length} models from ${providerId}.`, "ok")
432
  } finally {
433
  setFetchingId(null)
434
  }
435
  }
436
 
437
+ const addModels = (index: number, models: string[]) => {
438
+ const providerId = providerIdFor(index)
439
+ setModelsByProvider((prev) => ({
440
+ ...prev,
441
+ [providerId]: [...new Set([...(prev[providerId] ?? []), ...models])],
442
+ }))
443
+ // Manually added ids are deliberately typed, so apply them immediately.
444
+ const current = instances[index]?.models ?? []
445
+ updateInstance(index, { models: [...new Set([...current, ...models])] })
446
+ onAddModels(providerId, models)
447
+ onMessage(
448
+ `Added ${models.length} model id${models.length === 1 ? "" : "s"} to ${providerId}. Apply to save.`,
449
+ "ok",
450
+ )
451
+ }
452
+
453
+ const toggleModel = (index: number, model: string) => {
454
+ const current = instances[index]?.models ?? []
455
+ updateInstance(
456
+ index,
457
+ current.includes(model)
458
+ ? { models: current.filter((candidate) => candidate !== model) }
459
+ : { models: [...current, model] },
460
+ )
461
+ }
462
+
463
  const useModelAsDefault = (index: number, model: string) => {
464
  const route = `${providerIdFor(index)}/${model}`
465
  onValuesChange({ ...values, MODEL: route })
466
+ const current = instances[index]?.models ?? []
467
+ if (!current.includes(model)) {
468
+ updateInstance(index, { models: [...current, model] })
469
+ }
470
  onMessage(`Default model set to ${route}. Apply to save.`, "ok")
471
  }
472
 
 
480
 
481
  const legacyBaseUrl = values["OPENAI_COMPATIBLE_BASE_URL"] ?? ""
482
  const legacyKey = values["OPENAI_COMPATIBLE_API_KEY"] ?? ""
483
+ const cardModels = (index: number): string[] => {
484
+ const providerId = providerIdFor(index)
485
+ return [
486
+ ...new Set([
487
+ ...(instances[index]?.models ?? []),
488
+ ...(modelsByProvider[providerId] ?? []),
489
+ ]),
490
+ ]
491
+ }
492
+ const totalModels = instances.reduce(
493
+ (sum, _instance, index) => sum + cardModels(index).length,
494
+ 0,
495
+ )
496
 
497
  return (
498
  <section className="space-y-4">
499
  <div>
500
  <h3 className="text-lg font-semibold">Endpoints</h3>
501
+ <p className="mt-1 text-sm text-muted-foreground">
502
  Add any OpenAI-compatible server (vLLM, LM Studio, Ollama, Together, or
503
  your own gateway). Each endpoint becomes a numbered provider:{" "}
504
  <code>openai_compatible_1/&lt;model&gt;</code>,{" "}
505
+ <code>openai_compatible_2/&lt;model&gt;</code>, … Add as many model ids
506
+ as you want per endpoint, comma-separated, or use{" "}
507
+ <strong>Fetch models</strong> to pull them from the endpoint's{" "}
508
+ <code>/models</code> route. Tick the models you want applied — selected
509
+ ids are saved with the endpoint on <strong>Apply</strong> and appear in
510
+ the Model Config dropdowns after a reload.
511
  </p>
512
+ <div className="mt-3 flex flex-wrap gap-2">
513
+ <Badge variant="outline" className="gap-1.5">
514
+ <Server className="size-3.5 text-muted-foreground" />
515
+ {instances.length} endpoint{instances.length === 1 ? "" : "s"}
516
+ </Badge>
517
+ <Badge variant="outline" className="gap-1.5">
518
+ <ListChecks className="size-3.5 text-muted-foreground" />
519
+ {totalModels} model id{totalModels === 1 ? "" : "s"}
520
+ </Badge>
521
+ </div>
522
  </div>
523
 
524
  {instances.length === 0 && legacyBaseUrl.trim() ? (
 
536
  commit([{ base_url: legacyBaseUrl, api_keys: legacyKey, proxy: "" }])
537
  }
538
  >
539
+ <ListPlus />
540
  Import as endpoint 1
541
  </Button>
542
  </div>
 
544
 
545
  {instances.length === 0 ? (
546
  <Card className="border-dashed">
547
+ <CardContent className="flex flex-col items-center gap-3 py-12 text-center">
548
+ <span className="flex size-12 items-center justify-center rounded-full bg-primary/10">
549
+ <Server className="size-6 text-primary" />
550
+ </span>
551
  <p className="text-sm text-muted-foreground">
552
  No endpoints yet. Add your first OpenAI-compatible endpoint.
553
  </p>
554
  <Button type="button" onClick={addInstance}>
555
+ <Plus />
556
  Add endpoint
557
  </Button>
558
  </CardContent>
 
570
  locked={field.locked}
571
  testing={testingId === providerId}
572
  fetching={fetchingId === providerId}
573
+ models={cardModels(index)}
574
+ selected={instance.models ?? []}
575
+ currentDefault={values["MODEL"] ?? ""}
576
  onPatch={(patch) => updateInstance(index, patch)}
577
  onRemove={() => removeInstance(index)}
578
  onTest={() => {
 
580
  onTestProvider(providerId, () => setTestingId(null))
581
  }}
582
  onFetch={() => void fetchModels(index)}
583
+ onClearModels={() => {
584
  setModelsByProvider((prev) => ({ ...prev, [providerId]: [] }))
585
+ updateInstance(index, { models: [] })
586
+ }}
587
+ onAddModels={(models) => addModels(index, models)}
588
+ onToggleModel={(model) => toggleModel(index, model)}
589
  onUseModel={(model) => useModelAsDefault(index, model)}
590
  />
591
  )
592
  })}
593
+ <Button
594
+ type="button"
595
+ variant="outline"
596
+ className="w-full border-dashed"
597
+ onClick={addInstance}
598
+ >
599
+ <Plus />
600
+ Add endpoint
601
+ </Button>
602
  </div>
603
  )}
604
  </section>
frontend/src/components/ProviderCards.tsx CHANGED
@@ -1,7 +1,10 @@
1
  import { useState } from "react"
 
2
 
3
  import { statusClass } from "@/App"
 
4
  import type { ProviderStatus } from "@/lib/types"
 
5
  import { Badge } from "./ui/badge"
6
  import { Button } from "./ui/button"
7
  import { Card } from "./ui/card"
@@ -26,25 +29,44 @@ export function ProviderCard({ provider, state, onTest }: ProviderCardProps) {
26
  : provider.configuration || ""
27
 
28
  const label = isLocal ? "Test" : "Refresh models"
 
29
 
30
  return (
31
- <Card className="flex flex-row items-center justify-between gap-3 p-4">
32
- <div className="min-w-0">
33
- <div className="flex items-center gap-2">
34
- <strong className="text-sm">{provider.display_name || provider.provider_id}</strong>
35
- <Badge variant={statusClass(state.status)}>{state.label}</Badge>
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  </div>
37
- <p className="mt-1 truncate text-xs text-muted-foreground">{meta}</p>
38
  </div>
39
  <Button
40
  variant="secondary"
41
  size="sm"
 
42
  disabled={pending}
43
  onClick={() => {
44
  setPending(true)
45
  onTest(provider.provider_id, () => setPending(false))
46
  }}
47
  >
 
 
 
 
 
48
  {pending ? (isLocal ? "Testing" : "Refreshing") : label}
49
  </Button>
50
  </Card>
@@ -88,10 +110,26 @@ export function ConnectedAccountCard({
88
  const target = status.authorization_url || status.verification_url
89
 
90
  return (
91
- <Card className="flex flex-col gap-3 p-4">
92
- <div className="flex items-center gap-2">
93
- <strong className="text-sm">{provider.display_name || provider.provider_id}</strong>
94
- <Badge variant={statusClass(status.state)}>{status.label}</Badge>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  </div>
96
  <p className="text-xs text-muted-foreground">{statusMessage(status)}</p>
97
  <div className="flex flex-wrap gap-2">
@@ -135,6 +173,22 @@ export function ConnectedAccountCard({
135
  )
136
  }
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  function statusMessage(status: ConnectedState): string {
139
  if (status.connected) {
140
  const identity = status.email || "ChatGPT subscription connected"
@@ -151,4 +205,4 @@ function statusMessage(status: ConnectedState): string {
151
  return "Finish signing in, then return to this page."
152
  }
153
  return status.message || "Connect a ChatGPT account to discover subscription models."
154
- }
 
1
  import { useState } from "react"
2
+ import { Cable, Cpu, KeyRound, RefreshCw } from "lucide-react"
3
 
4
  import { statusClass } from "@/App"
5
+ import { statusDotClass } from "@/lib/status"
6
  import type { ProviderStatus } from "@/lib/types"
7
+ import { cn } from "@/lib/utils"
8
  import { Badge } from "./ui/badge"
9
  import { Button } from "./ui/button"
10
  import { Card } from "./ui/card"
 
29
  : provider.configuration || ""
30
 
31
  const label = isLocal ? "Test" : "Refresh models"
32
+ const Icon = isLocal ? Cpu : Cable
33
 
34
  return (
35
+ <Card className="flex flex-row items-center justify-between gap-3 p-4 transition-shadow hover:shadow-md">
36
+ <div className="flex min-w-0 items-center gap-3">
37
+ <span
38
+ className={cn(
39
+ "flex size-10 shrink-0 items-center justify-center rounded-lg",
40
+ isLocal ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
41
+ )}
42
+ >
43
+ <Icon className="size-4.5" />
44
+ </span>
45
+ <div className="min-w-0">
46
+ <div className="flex items-center gap-2">
47
+ <strong className="truncate text-sm">
48
+ {provider.display_name || provider.provider_id}
49
+ </strong>
50
+ <StatusBadge status={state.status} label={state.label} />
51
+ </div>
52
+ <p className="mt-1 truncate text-xs text-muted-foreground">{meta}</p>
53
  </div>
 
54
  </div>
55
  <Button
56
  variant="secondary"
57
  size="sm"
58
+ className="shrink-0"
59
  disabled={pending}
60
  onClick={() => {
61
  setPending(true)
62
  onTest(provider.provider_id, () => setPending(false))
63
  }}
64
  >
65
+ {pending ? (
66
+ <RefreshCw className="animate-spin" />
67
+ ) : (
68
+ <Icon className="size-4" />
69
+ )}
70
  {pending ? (isLocal ? "Testing" : "Refreshing") : label}
71
  </Button>
72
  </Card>
 
110
  const target = status.authorization_url || status.verification_url
111
 
112
  return (
113
+ <Card className="flex flex-col gap-3 p-4 transition-shadow hover:shadow-md">
114
+ <div className="flex items-center gap-3">
115
+ <span
116
+ className={cn(
117
+ "flex size-10 shrink-0 items-center justify-center rounded-lg",
118
+ connected
119
+ ? "bg-emerald-500/10 text-emerald-600"
120
+ : connecting
121
+ ? "bg-amber-500/10 text-amber-600"
122
+ : "bg-muted text-muted-foreground",
123
+ )}
124
+ >
125
+ <KeyRound className="size-4.5" />
126
+ </span>
127
+ <div className="flex min-w-0 flex-1 items-center gap-2">
128
+ <strong className="truncate text-sm">
129
+ {provider.display_name || provider.provider_id}
130
+ </strong>
131
+ <StatusBadge status={status.state} label={status.label} />
132
+ </div>
133
  </div>
134
  <p className="text-xs text-muted-foreground">{statusMessage(status)}</p>
135
  <div className="flex flex-wrap gap-2">
 
173
  )
174
  }
175
 
176
+ function StatusBadge({ status, label }: { status: string; label: string }) {
177
+ const connecting = status === "connecting"
178
+ return (
179
+ <Badge variant={statusClass(status)} className="gap-1.5">
180
+ <span
181
+ className={cn(
182
+ "size-1.5 rounded-full",
183
+ statusDotClass(status),
184
+ connecting && "animate-pulse",
185
+ )}
186
+ />
187
+ {label}
188
+ </Badge>
189
+ )
190
+ }
191
+
192
  function statusMessage(status: ConnectedState): string {
193
  if (status.connected) {
194
  const identity = status.email || "ChatGPT subscription connected"
 
205
  return "Finish signing in, then return to this page."
206
  }
207
  return status.message || "Connect a ChatGPT account to discover subscription models."
208
+ }
frontend/src/components/Sidebar.tsx CHANGED
@@ -1,47 +1,100 @@
 
 
1
  import { cn } from "@/lib/utils"
2
  import type { AdminConfig } from "@/lib/types"
3
 
4
  import { VIEW_GROUPS } from "../App"
 
 
5
 
6
  interface SidebarProps {
7
  config: AdminConfig
8
  activeView: string
9
  onSelectView: (view: string) => void
 
 
 
10
  }
11
 
12
- export function Sidebar({ config, activeView, onSelectView }: SidebarProps) {
 
 
 
 
 
 
13
  return (
14
- <aside className="flex w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
15
- <div className="flex items-center gap-3 px-6 py-5">
16
- <div className="flex size-10 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
17
- FC
 
 
 
 
 
18
  </div>
19
- <div>
20
- <h1 className="text-base font-semibold leading-tight">Free Claude Code</h1>
 
 
21
  <p className="text-xs text-muted-foreground">Server Control</p>
22
  </div>
23
  </div>
 
 
 
 
 
24
  <nav className="flex flex-col gap-1 px-3" aria-label="Admin views">
25
- {VIEW_GROUPS.map((view) => (
26
- <button
27
- key={view.id}
28
- type="button"
29
- className={cn(
30
- "rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
31
- view.id === activeView
32
- ? "bg-accent text-accent-foreground"
33
- : "hover:bg-accent/50",
34
- )}
35
- aria-current={view.id === activeView ? "page" : undefined}
36
- onClick={() => onSelectView(view.id)}
37
- >
38
- {view.label}
39
- </button>
40
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </nav>
42
- <div className="mt-auto px-6 py-4 text-xs text-muted-foreground">
43
- <p className="break-all">{config.paths.managed}</p>
 
 
 
 
 
 
 
 
44
  </div>
45
  </aside>
46
  )
47
- }
 
1
+ import { Bot, FileCog } from "lucide-react"
2
+
3
  import { cn } from "@/lib/utils"
4
  import type { AdminConfig } from "@/lib/types"
5
 
6
  import { VIEW_GROUPS } from "../App"
7
+ import { Separator } from "./ui/separator"
8
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
9
 
10
  interface SidebarProps {
11
  config: AdminConfig
12
  activeView: string
13
  onSelectView: (view: string) => void
14
+ /** Called after a view is selected; used by the mobile drawer to close. */
15
+ onNavigate?: () => void
16
+ className?: string
17
  }
18
 
19
+ export function Sidebar({
20
+ config,
21
+ activeView,
22
+ onSelectView,
23
+ onNavigate,
24
+ className,
25
+ }: SidebarProps) {
26
  return (
27
+ <aside
28
+ className={cn(
29
+ "flex w-64 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground",
30
+ className,
31
+ )}
32
+ >
33
+ <div className="flex items-center gap-3 px-5 py-5">
34
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/70 text-primary-foreground shadow-sm">
35
+ <Bot className="size-5" />
36
  </div>
37
+ <div className="min-w-0">
38
+ <h1 className="truncate text-sm leading-tight font-semibold">
39
+ Free Claude Code
40
+ </h1>
41
  <p className="text-xs text-muted-foreground">Server Control</p>
42
  </div>
43
  </div>
44
+
45
+ <div className="px-5 pt-1 pb-2 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase">
46
+ Configuration
47
+ </div>
48
+
49
  <nav className="flex flex-col gap-1 px-3" aria-label="Admin views">
50
+ {VIEW_GROUPS.map((view) => {
51
+ const isActive = view.id === activeView
52
+ const Icon = view.icon
53
+ return (
54
+ <Tooltip key={view.id}>
55
+ <TooltipTrigger asChild>
56
+ <button
57
+ type="button"
58
+ className={cn(
59
+ "relative flex items-center gap-3 rounded-md px-3 py-2 text-left text-sm font-medium transition-colors",
60
+ isActive
61
+ ? "bg-accent text-accent-foreground"
62
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
63
+ )}
64
+ aria-current={isActive ? "page" : undefined}
65
+ onClick={() => {
66
+ onSelectView(view.id)
67
+ onNavigate?.()
68
+ }}
69
+ >
70
+ {isActive ? (
71
+ <span className="absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-primary" />
72
+ ) : null}
73
+ <Icon
74
+ className={cn(
75
+ "size-4 shrink-0",
76
+ isActive ? "text-primary" : "text-muted-foreground",
77
+ )}
78
+ />
79
+ {view.label}
80
+ </button>
81
+ </TooltipTrigger>
82
+ <TooltipContent side="right">{view.description}</TooltipContent>
83
+ </Tooltip>
84
+ )
85
+ })}
86
  </nav>
87
+
88
+ <div className="mt-auto space-y-2 px-5 py-4">
89
+ <Separator />
90
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
91
+ <FileCog className="size-3.5 shrink-0" />
92
+ Managed config
93
+ </div>
94
+ <p className="break-all font-mono text-[11px] leading-relaxed text-muted-foreground/80">
95
+ {config.paths.managed}
96
+ </p>
97
  </div>
98
  </aside>
99
  )
100
+ }
frontend/src/components/ThemeToggle.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Moon, Sun } from "lucide-react"
2
+
3
+ import { useTheme } from "./theme-provider"
4
+ import { Button } from "./ui/button"
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
6
+
7
+ export function ThemeToggle() {
8
+ const { resolvedTheme, toggleTheme } = useTheme()
9
+ const isDark = resolvedTheme === "dark"
10
+
11
+ return (
12
+ <Tooltip>
13
+ <TooltipTrigger asChild>
14
+ <Button
15
+ type="button"
16
+ variant="ghost"
17
+ size="icon"
18
+ aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
19
+ onClick={toggleTheme}
20
+ >
21
+ {isDark ? <Sun /> : <Moon />}
22
+ </Button>
23
+ </TooltipTrigger>
24
+ <TooltipContent side="bottom">
25
+ {isDark ? "Switch to light theme" : "Switch to dark theme"}
26
+ </TooltipContent>
27
+ </Tooltip>
28
+ )
29
+ }
frontend/src/components/theme-provider.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useEffect, useState } from "react"
2
+ import type { ReactNode } from "react"
3
+
4
+ const THEME_KEY = "fcc-admin-theme"
5
+
6
+ export type Theme = "light" | "dark" | "system"
7
+
8
+ interface ThemeContextValue {
9
+ theme: Theme
10
+ /** Resolved appearance currently applied to the document. */
11
+ resolvedTheme: "light" | "dark"
12
+ setTheme: (theme: Theme) => void
13
+ toggleTheme: () => void
14
+ }
15
+
16
+ const ThemeContext = createContext<ThemeContextValue | null>(null)
17
+
18
+ function readStoredTheme(): Theme {
19
+ try {
20
+ const stored = window.localStorage.getItem(THEME_KEY)
21
+ if (stored === "light" || stored === "dark" || stored === "system") return stored
22
+ } catch {
23
+ // Storage may be unavailable (private mode); fall back to system.
24
+ }
25
+ return "system"
26
+ }
27
+
28
+ function readSystemTheme(): "light" | "dark" {
29
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
30
+ }
31
+
32
+ function applyTheme(theme: Theme): "light" | "dark" {
33
+ const resolved = theme === "system" ? readSystemTheme() : theme
34
+ document.documentElement.classList.toggle("dark", resolved === "dark")
35
+ return resolved
36
+ }
37
+
38
+ export function ThemeProvider({ children }: { children: ReactNode }) {
39
+ const [theme, setThemeState] = useState<Theme>(readStoredTheme)
40
+ const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() =>
41
+ applyTheme(readStoredTheme()),
42
+ )
43
+
44
+ useEffect(() => {
45
+ setResolvedTheme(applyTheme(theme))
46
+ if (theme !== "system") return
47
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
48
+ const onChange = () => setResolvedTheme(applyTheme("system"))
49
+ media.addEventListener("change", onChange)
50
+ return () => media.removeEventListener("change", onChange)
51
+ }, [theme])
52
+
53
+ const setTheme = (next: Theme) => {
54
+ setThemeState(next)
55
+ try {
56
+ window.localStorage.setItem(THEME_KEY, next)
57
+ } catch {
58
+ // Non-persistent storage: the toggle still applies for this session.
59
+ }
60
+ }
61
+
62
+ const toggleTheme = () => {
63
+ setTheme(resolvedTheme === "dark" ? "light" : "dark")
64
+ }
65
+
66
+ return (
67
+ <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
68
+ {children}
69
+ </ThemeContext.Provider>
70
+ )
71
+ }
72
+
73
+ export function useTheme(): ThemeContextValue {
74
+ const context = useContext(ThemeContext)
75
+ if (!context) {
76
+ throw new Error("useTheme must be used within a ThemeProvider")
77
+ }
78
+ return context
79
+ }
frontend/src/components/ui/separator.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Separator({
7
+ className,
8
+ orientation = "horizontal",
9
+ decorative = true,
10
+ ...props
11
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
12
+ return (
13
+ <SeparatorPrimitive.Root
14
+ data-slot="separator"
15
+ decorative={decorative}
16
+ orientation={orientation}
17
+ className={cn(
18
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
19
+ className,
20
+ )}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ export { Separator }
frontend/src/components/ui/skeleton.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils"
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("bg-accent animate-pulse rounded-md", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
12
+
13
+ export { Skeleton }
frontend/src/components/ui/tooltip.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function TooltipProvider({
7
+ delayDuration = 0,
8
+ ...props
9
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
10
+ return (
11
+ <TooltipPrimitive.Provider
12
+ data-slot="tooltip-provider"
13
+ delayDuration={delayDuration}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
20
+ return (
21
+ <TooltipProvider>
22
+ <TooltipPrimitive.Root data-slot="tooltip" {...props} />
23
+ </TooltipProvider>
24
+ )
25
+ }
26
+
27
+ function TooltipTrigger({
28
+ ...props
29
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
30
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
31
+ }
32
+
33
+ function TooltipContent({
34
+ className,
35
+ sideOffset = 6,
36
+ children,
37
+ ...props
38
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
39
+ return (
40
+ <TooltipPrimitive.Portal>
41
+ <TooltipPrimitive.Content
42
+ data-slot="tooltip-content"
43
+ sideOffset={sideOffset}
44
+ className={cn(
45
+ "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
46
+ className,
47
+ )}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
52
+ </TooltipPrimitive.Content>
53
+ </TooltipPrimitive.Portal>
54
+ )
55
+ }
56
+
57
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
frontend/src/index.css CHANGED
@@ -1,4 +1,5 @@
1
  @import "tailwindcss";
 
2
 
3
  @custom-variant dark (&:is(.dark *));
4
 
@@ -83,7 +84,23 @@
83
  border-color: var(--border);
84
  }
85
 
 
 
 
 
 
 
 
 
86
  body {
87
  background-color: var(--background);
88
  color: var(--foreground);
 
 
 
 
 
 
 
 
89
  }
 
1
  @import "tailwindcss";
2
+ @import "tw-animate-css";
3
 
4
  @custom-variant dark (&:is(.dark *));
5
 
 
84
  border-color: var(--border);
85
  }
86
 
87
+ html {
88
+ color-scheme: light;
89
+ }
90
+
91
+ html.dark {
92
+ color-scheme: dark;
93
+ }
94
+
95
  body {
96
  background-color: var(--background);
97
  color: var(--foreground);
98
+ -webkit-font-smoothing: antialiased;
99
+ text-rendering: optimizeLegibility;
100
+ }
101
+
102
+ /* Slim scrollbars for dense lists (model pickers, endpoint model lists). */
103
+ * {
104
+ scrollbar-width: thin;
105
+ scrollbar-color: var(--border) transparent;
106
  }
frontend/src/lib/status.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Dot color for a provider/endpoint status badge, shared by all status cards. */
2
+ export function statusDotClass(status: string | undefined): string {
3
+ if (["configured", "reachable", "running", "connected"].includes(status ?? ""))
4
+ return "bg-emerald-500"
5
+ if (
6
+ ["missing_key", "missing_config", "missing_url", "unknown", "connecting"].includes(
7
+ status ?? "",
8
+ )
9
+ )
10
+ return "bg-amber-500"
11
+ if (["offline", "error"].includes(status ?? "")) return "bg-red-500"
12
+ return "bg-muted-foreground/50"
13
+ }
frontend/src/lib/types.ts CHANGED
@@ -113,4 +113,6 @@ export interface OpenAICompatibleInstance {
113
  base_url: string
114
  api_keys: string
115
  proxy: string
 
 
116
  }
 
113
  base_url: string
114
  api_keys: string
115
  proxy: string
116
+ /** Model ids applied to this endpoint; persisted on Apply. */
117
+ models?: string[]
118
  }
frontend/src/main.tsx CHANGED
@@ -2,10 +2,13 @@ import { StrictMode } from "react"
2
  import { createRoot } from "react-dom/client"
3
 
4
  import App from "./App"
 
5
  import "./index.css"
6
 
7
  createRoot(document.getElementById("root")!).render(
8
  <StrictMode>
9
- <App />
 
 
10
  </StrictMode>,
11
- )
 
2
  import { createRoot } from "react-dom/client"
3
 
4
  import App from "./App"
5
+ import { ThemeProvider } from "./components/theme-provider"
6
  import "./index.css"
7
 
8
  createRoot(document.getElementById("root")!).render(
9
  <StrictMode>
10
+ <ThemeProvider>
11
+ <App />
12
+ </ThemeProvider>
13
  </StrictMode>,
14
+ )
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "4.23.2"
8
  description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
 
4
 
5
  [project]
6
  name = "free-claude-code"
7
+ version = "4.25.0"
8
  description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
src/free_claude_code/api/admin_routes.py CHANGED
@@ -254,18 +254,26 @@ def _model_options(
254
  *,
255
  refresh_result: ProviderModelRefreshResult | None = None,
256
  ) -> dict[str, list[str]]:
257
- configured = {
258
- ref.model_ref
259
- for ref in configured_chat_model_refs(services.requests.current_settings())
260
- }
261
  discovered = {
262
  info.model_id for info in services.requests.cached_prefixed_model_infos()
263
  }
 
 
 
 
 
 
 
264
  failed_provider_ids = (
265
  refresh_result.failed_provider_ids if refresh_result is not None else ()
266
  )
267
  return {
268
- "models": sorted(configured | discovered, key=str.casefold),
 
 
 
269
  "failed_providers": list(failed_provider_ids),
270
  }
271
 
 
254
  *,
255
  refresh_result: ProviderModelRefreshResult | None = None,
256
  ) -> dict[str, list[str]]:
257
+ settings = services.requests.current_settings()
258
+ configured = {ref.model_ref for ref in configured_chat_model_refs(settings)}
 
 
259
  discovered = {
260
  info.model_id for info in services.requests.cached_prefixed_model_infos()
261
  }
262
+ stored_instance_models = {
263
+ f"openai_compatible_{index}/{model.strip()}"
264
+ for index, instance in enumerate(settings.openai_compatible_instances, start=1)
265
+ if instance.base_url.strip()
266
+ for model in instance.models
267
+ if model.strip()
268
+ }
269
  failed_provider_ids = (
270
  refresh_result.failed_provider_ids if refresh_result is not None else ()
271
  )
272
  return {
273
+ "models": sorted(
274
+ configured | discovered | stored_instance_models,
275
+ key=str.casefold,
276
+ ),
277
  "failed_providers": list(failed_provider_ids),
278
  }
279
 
src/free_claude_code/api/admin_static/assets/admin-6Z5kWe4I.css DELETED
@@ -1 +0,0 @@
1
- /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-800:oklch(43.2% .095 166.913);--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--leading-tight:1.25;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.inset-y-0{inset-block:0}.right-2{right:calc(var(--spacing) * 2)}.z-10{z-index:10}.mt-1{margin-top:var(--spacing)}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-9{width:calc(var(--spacing) * 9)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-input{border-color:var(--input)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--accent)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-background{background-color:var(--background)}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-transparent{background-color:#0000}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-10{padding-block:calc(var(--spacing) * 10)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-800{color:var(--color-emerald-800)}.text-foreground{color:var(--foreground)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground{color:var(--sidebar-foreground)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media(hover:hover){.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}}:root{--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(95.6% .012 81.69);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(90.9% .01 58.071);--input:oklch(90.9% .01 58.071);--ring:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% .001 106.423);--sidebar-foreground:oklch(14.7% .004 49.25)}.dark{--background:oklch(14.7% .004 285.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(85.1% .2 91.936);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(77.5% .024 90.004);--accent:oklch(31.7% .012 45.683);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.7% .222 23.322);--border:oklch(100% 0 0/.15);--input:oklch(100% 0 0/.3);--ring:oklch(95.2% .129 103.88);--sidebar:oklch(21.6% .006 56.043);--sidebar-foreground:oklch(98.5% .001 106.423)}:root{--radius:.625rem}*{border-color:var(--border)}body{background-color:var(--background);color:var(--foreground)}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}
 
 
src/free_claude_code/api/admin_static/assets/admin-Brc4d8H7.css ADDED
@@ -0,0 +1 @@
 
 
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-800:oklch(43.2% .095 166.913);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--leading-tight:1.25;--leading-relaxed:1.625;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-y-0{inset-block:0}.inset-y-1\.5{inset-block:calc(var(--spacing) * 1.5)}.top-0{top:0}.top-4{top:calc(var(--spacing) * 4)}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.z-10{z-index:10}.z-30{z-index:30}.z-50{z-index:50}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-auto{margin-top:auto}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-4\.5{width:calc(var(--spacing) * 4.5);height:calc(var(--spacing) * 4.5)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.h-4{height:calc(var(--spacing) * 4)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-36{height:calc(var(--spacing) * 36)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-3\/6{width:50%}.w-4\/6{width:66.6667%}.w-5\/6{width:83.3333%}.w-9{width:calc(var(--spacing) * 9)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-fit{width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.origin-\(--radix-tooltip-content-transform-origin\){transform-origin:var(--radix-tooltip-content-transform-origin)}.translate-y-\[calc\(-50\%_-_2px\)\]{--tw-translate-y: calc(-50% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-45{rotate:45deg}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0{gap:0}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded-\[2px\]{border-radius:2px}.rounded-\[4px\]{border-radius:4px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-input{border-color:var(--input)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--accent)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/10{background-color:color-mix(in oklab,var(--color-amber-500) 10%,transparent)}}.bg-background,.bg-background\/80{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,var(--background) 80%,transparent)}}.bg-background\/90{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/90{background-color:color-mix(in oklab,var(--background) 90%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black) 60%,transparent)}}.bg-border{background-color:var(--border)}.bg-card,.bg-card\/40{background-color:var(--card)}@supports (color:color-mix(in lab,red,red)){.bg-card\/40{background-color:color-mix(in oklab,var(--card) 40%,transparent)}}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50\/70{background-color:#ecfdf5b3}@supports (color:color-mix(in lab,red,red)){.bg-emerald-50\/70{background-color:color-mix(in oklab,var(--color-emerald-50) 70%,transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/50{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/50{background-color:color-mix(in oklab,var(--muted-foreground) 50%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted) 30%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary) 10%,transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-transparent{background-color:#0000}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-primary{--tw-gradient-from:var(--primary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-primary\/70{--tw-gradient-to:var(--primary)}@supports (color:color-mix(in lab,red,red)){.to-primary\/70{--tw-gradient-to:color-mix(in oklab, var(--primary) 70%, transparent)}}.to-primary\/70{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-amber-400{fill:var(--color-amber-400)}.fill-primary{fill:var(--primary)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-1{padding-top:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-balance{text-wrap:balance}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-accent-foreground{color:var(--accent-foreground)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-800{color:var(--color-emerald-800)}.text-foreground{color:var(--foreground)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground) 80%,transparent)}}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground{color:var(--sidebar-foreground)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-primary\/20{--tw-ring-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.ring-primary\/20{--tw-ring-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.running{animation-play-state:running}.slide-in-from-left{--tw-enter-translate-x:-100%}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}@media(hover:hover){.hover\:border-border:hover,.hover\:border-border\/80:hover{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.hover\:border-border\/80:hover{border-color:color-mix(in oklab,var(--border) 80%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:h-full[data-orientation=vertical]{height:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}@media(min-width:40rem){.sm\:inline-flex{display:inline-flex}}@media(min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:inline{display:inline}}.dark\:border-emerald-500\/30:is(.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.dark\:border-emerald-500\/30:is(.dark *){border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.dark\:bg-emerald-500\/10:is(.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-emerald-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--background:oklch(100% 0 0);--foreground:oklch(14.7% .004 49.25);--card:oklch(100% 0 0);--card-foreground:oklch(14.7% .004 49.25);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.7% .004 49.25);--primary:oklch(21.6% .006 56.043);--primary-foreground:oklch(98.5% .001 106.423);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(21.6% .006 56.043);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.3% .013 58.071);--accent:oklch(95.6% .012 81.69);--accent-foreground:oklch(21.6% .006 56.043);--destructive:oklch(57.7% .245 27.325);--border:oklch(90.9% .01 58.071);--input:oklch(90.9% .01 58.071);--ring:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% .001 106.423);--sidebar-foreground:oklch(14.7% .004 49.25)}.dark{--background:oklch(14.7% .004 285.25);--foreground:oklch(98.5% .001 106.423);--card:oklch(21.6% .006 56.043);--card-foreground:oklch(98.5% .001 106.423);--popover:oklch(21.6% .006 56.043);--popover-foreground:oklch(98.5% .001 106.423);--primary:oklch(85.1% .2 91.936);--primary-foreground:oklch(21.6% .006 56.043);--secondary:oklch(26.8% .007 34.298);--secondary-foreground:oklch(98.5% .001 106.423);--muted:oklch(26.8% .007 34.298);--muted-foreground:oklch(77.5% .024 90.004);--accent:oklch(31.7% .012 45.683);--accent-foreground:oklch(98.5% .001 106.423);--destructive:oklch(70.7% .222 23.322);--border:oklch(100% 0 0/.15);--input:oklch(100% 0 0/.3);--ring:oklch(95.2% .129 103.88);--sidebar:oklch(21.6% .006 56.043);--sidebar-foreground:oklch(98.5% .001 106.423)}:root{--radius:.625rem}*{border-color:var(--border)}html{color-scheme:light}html.dark{color-scheme:dark}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility}*{scrollbar-width:thin;scrollbar-color:var(--border) transparent}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"<color>";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"<length-percentage>";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"<length-percentage>";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"<length-percentage>";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}
src/free_claude_code/api/admin_static/assets/admin-D-op9N5Q.js ADDED
The diff for this file is too large to render. See raw diff
 
src/free_claude_code/api/admin_static/assets/admin-GjR0Z_x1.js DELETED
The diff for this file is too large to render. See raw diff
 
src/free_claude_code/api/admin_static/index.html CHANGED
@@ -5,8 +5,8 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <link rel="icon" href="data:," />
7
  <title>Free Claude Code Admin</title>
8
- <script type="module" crossorigin src="/admin/assets/admin-GjR0Z_x1.js"></script>
9
- <link rel="stylesheet" crossorigin href="/admin/assets/admin-6Z5kWe4I.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <link rel="icon" href="data:," />
7
  <title>Free Claude Code Admin</title>
8
+ <script type="module" crossorigin src="/admin/assets/admin-D-op9N5Q.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/admin/assets/admin-Brc4d8H7.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>
src/free_claude_code/config/admin/status.py CHANGED
@@ -114,6 +114,15 @@ def _openai_compatible_instance_statuses(raw: str) -> list[dict[str, Any]]:
114
  continue
115
  base_url = str(instance.get("base_url", "") or "")
116
  configured = bool(base_url.strip())
 
 
 
 
 
 
 
 
 
117
  statuses.append(
118
  {
119
  "provider_id": f"openai_compatible_{index}",
@@ -123,6 +132,7 @@ def _openai_compatible_instance_statuses(raw: str) -> list[dict[str, Any]]:
123
  "label": "Configured" if configured else "Missing base URL",
124
  "base_url": base_url,
125
  "configuration": "OPENAI_COMPATIBLE_BASE_URL",
 
126
  }
127
  )
128
  return statuses
 
114
  continue
115
  base_url = str(instance.get("base_url", "") or "")
116
  configured = bool(base_url.strip())
117
+ raw_models = instance.get("models")
118
+ if isinstance(raw_models, list):
119
+ models = [
120
+ str(model)
121
+ for model in raw_models
122
+ if isinstance(model, str) and model.strip()
123
+ ]
124
+ else:
125
+ models = []
126
  statuses.append(
127
  {
128
  "provider_id": f"openai_compatible_{index}",
 
132
  "label": "Configured" if configured else "Missing base URL",
133
  "base_url": base_url,
134
  "configuration": "OPENAI_COMPATIBLE_BASE_URL",
135
+ "models": models,
136
  }
137
  )
138
  return statuses
src/free_claude_code/config/settings.py CHANGED
@@ -27,6 +27,11 @@ class OpenAICompatibleInstance(BaseModel):
27
  base_url: str = ""
28
  api_keys: str = ""
29
  proxy: str = ""
 
 
 
 
 
30
 
31
 
32
  class Settings(BaseSettings):
 
27
  base_url: str = ""
28
  api_keys: str = ""
29
  proxy: str = ""
30
+ # Model ids applied to this endpoint from the admin "OpenAI-Compatible
31
+ # Endpoints" page. Each id is routable as ``openai_compatible_N/<model>``
32
+ # and listed in the admin model options even when the endpoint's live
33
+ # ``/models`` route is unreachable.
34
+ models: tuple[str, ...] = ()
35
 
36
 
37
  class Settings(BaseSettings):
tests/api/test_admin.py CHANGED
@@ -16,7 +16,7 @@ from free_claude_code.application.model_metadata import (
16
  )
17
  from free_claude_code.config.admin.values import MASKED_SECRET
18
  from free_claude_code.config.server_urls import local_admin_url
19
- from free_claude_code.config.settings import Settings
20
  from tests.api.support import create_test_app, provider_manager_for_app
21
 
22
 
@@ -365,6 +365,19 @@ def test_admin_static_model_combobox_preserves_custom_slugs_and_none_semantics()
365
  assert '"warn"' in app_source
366
 
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
369
  _set_home(monkeypatch, tmp_path)
370
  _clear_process_config(monkeypatch)
@@ -490,6 +503,35 @@ def test_admin_models_include_configured_and_cached_canonical_slugs():
490
  }
491
 
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  def test_admin_model_refresh_returns_the_updated_canonical_catalog():
494
  settings = Settings()
495
  settings.model = "deepseek/deepseek-chat"
 
16
  )
17
  from free_claude_code.config.admin.values import MASKED_SECRET
18
  from free_claude_code.config.server_urls import local_admin_url
19
+ from free_claude_code.config.settings import OpenAICompatibleInstance, Settings
20
  from tests.api.support import create_test_app, provider_manager_for_app
21
 
22
 
 
365
  assert '"warn"' in app_source
366
 
367
 
368
+ def test_admin_openai_compatible_view_supports_manual_model_entries():
369
+ view = Path("frontend/src/components/OpenAICompatibleView.tsx").read_text(
370
+ encoding="utf-8"
371
+ )
372
+ app_source = Path("frontend/src/App.tsx").read_text(encoding="utf-8")
373
+ assert "Add model id, comma-separated" in view
374
+ assert 'split(",")' in view
375
+ assert "onAddModels" in view
376
+ assert "Use as default" in view
377
+ assert "handleAddEndpointModels" in app_source
378
+ assert "onAddModels={handleAddEndpointModels}" in app_source
379
+
380
+
381
  def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path):
382
  _set_home(monkeypatch, tmp_path)
383
  _clear_process_config(monkeypatch)
 
503
  }
504
 
505
 
506
+ def test_admin_models_include_stored_openai_compatible_instance_models():
507
+ """Applied per-endpoint model ids are listed even without live discovery."""
508
+ settings = Settings()
509
+ settings.model = "nvidia_nim/configured-model"
510
+ settings.openai_compatible_instances = (
511
+ OpenAICompatibleInstance(
512
+ base_url="https://a.example/v1",
513
+ models=("gpt-4o", "deepseek-v3"),
514
+ ),
515
+ OpenAICompatibleInstance(base_url=""),
516
+ OpenAICompatibleInstance(
517
+ base_url="https://b.example",
518
+ models=(" local-model ", ""),
519
+ ),
520
+ )
521
+ app = create_test_app(settings)
522
+ response = _local_client(app).get("/admin/api/models")
523
+ assert response.status_code == 200
524
+ assert response.json() == {
525
+ "models": [
526
+ "nvidia_nim/configured-model",
527
+ "openai_compatible_1/deepseek-v3",
528
+ "openai_compatible_1/gpt-4o",
529
+ "openai_compatible_3/local-model",
530
+ ],
531
+ "failed_providers": [],
532
+ }
533
+
534
+
535
  def test_admin_model_refresh_returns_the_updated_canonical_catalog():
536
  settings = Settings()
537
  settings.model = "deepseek/deepseek-chat"
tests/config/test_config.py CHANGED
@@ -673,6 +673,31 @@ class TestOpenAICompatibleInstances:
673
  ),
674
  )
675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  def test_instances_empty_json_array(self, monkeypatch):
677
  """'[]' decodes to an empty tuple."""
678
  from free_claude_code.config.settings import Settings
 
673
  ),
674
  )
675
 
676
+ def test_instances_parse_applied_models(self, monkeypatch):
677
+ """Applied model ids persist per instance and default to empty."""
678
+ from free_claude_code.config.settings import (
679
+ OpenAICompatibleInstance,
680
+ Settings,
681
+ )
682
+
683
+ monkeypatch.setenv(
684
+ "OPENAI_COMPATIBLE_INSTANCES",
685
+ '[{"base_url": "https://a.example/v1", "models": ["gpt-4o", '
686
+ '"deepseek-v3"]}, {"base_url": "https://b.example"}]',
687
+ )
688
+ monkeypatch.setitem(Settings.model_config, "env_file", ())
689
+
690
+ instances = Settings().openai_compatible_instances
691
+
692
+ assert instances == (
693
+ OpenAICompatibleInstance(
694
+ base_url="https://a.example/v1",
695
+ models=("gpt-4o", "deepseek-v3"),
696
+ ),
697
+ OpenAICompatibleInstance(base_url="https://b.example"),
698
+ )
699
+ assert instances[1].models == ()
700
+
701
  def test_instances_empty_json_array(self, monkeypatch):
702
  """'[]' decodes to an empty tuple."""
703
  from free_claude_code.config.settings import Settings
tests/contracts/test_admin_provider_manifest.py CHANGED
@@ -259,7 +259,8 @@ def test_openai_compatible_instance_status_entries_numbered() -> None:
259
  state = {
260
  "OPENAI_COMPATIBLE_INSTANCES": {
261
  "value": (
262
- '[{"base_url": "https://a.example/v1", "api_keys": "k1"}, '
 
263
  '{"base_url": ""}]'
264
  )
265
  },
@@ -278,6 +279,8 @@ def test_openai_compatible_instance_status_entries_numbered() -> None:
278
  assert by_provider["openai_compatible_1"]["display_name"] == (
279
  "OpenAI-Compatible Endpoint #1"
280
  )
 
281
 
282
  assert by_provider["openai_compatible_2"]["status"] == "missing_config"
283
  assert by_provider["openai_compatible_2"]["label"] == "Missing base URL"
 
 
259
  state = {
260
  "OPENAI_COMPATIBLE_INSTANCES": {
261
  "value": (
262
+ '[{"base_url": "https://a.example/v1", "api_keys": "k1", '
263
+ '"models": ["gpt-4o"]}, '
264
  '{"base_url": ""}]'
265
  )
266
  },
 
279
  assert by_provider["openai_compatible_1"]["display_name"] == (
280
  "OpenAI-Compatible Endpoint #1"
281
  )
282
+ assert by_provider["openai_compatible_1"]["models"] == ["gpt-4o"]
283
 
284
  assert by_provider["openai_compatible_2"]["status"] == "missing_config"
285
  assert by_provider["openai_compatible_2"]["label"] == "Missing base URL"
286
+ assert by_provider["openai_compatible_2"]["models"] == []
uv.lock CHANGED
@@ -620,7 +620,7 @@ wheels = [
620
 
621
  [[package]]
622
  name = "free-claude-code"
623
- version = "4.23.2"
624
  source = { editable = "." }
625
  dependencies = [
626
  { name = "aiohttp" },
 
620
 
621
  [[package]]
622
  name = "free-claude-code"
623
+ version = "4.25.0"
624
  source = { editable = "." }
625
  dependencies = [
626
  { name = "aiohttp" },