diff --git a/.env.example b/.env.example index 160438e..1bdd664 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,9 @@ HOST=127.0.0.1 # Vite dev dashboard. Only set this if you serve the dashboard from a # different host than the API (e.g. http://my-server.local). # DASHBOARD_ORIGINS=http://my-server.local,http://192.168.1.50 + +# Optional request and backup ceilings. The public OpenAI-compatible JSON body +# limit defaults to 16mb. Streamed database export/import defaults to 4 GiB; +# legacy base64 JSON backups retain a 128 MiB binary compatibility ceiling. +# LLMHARBOR_API_BODY_LIMIT=16mb +# LLMHARBOR_MAX_BACKUP_BYTES=4294967296 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f048440..c800e30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,115 @@ name: CI +permissions: + contents: read + on: push: branches: [main] pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + scripts: + name: Validate command-line scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate Bash scripts + run: | + bash -n bin/llmharbor install.sh install-macos.sh + shellcheck bin/llmharbor install.sh install-macos.sh + + - name: Parse PowerShell scripts + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($file in @('bin/llmharbor.ps1', 'install.ps1')) { + [void][scriptblock]::Create((Get-Content -LiteralPath $file -Raw)) + } + + scripts-native: + name: Native CLI (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check macOS Bash entry points + if: runner.os == 'macOS' + shell: bash + run: | + /bin/bash -n bin/llmharbor install.sh install-macos.sh + /bin/bash bin/llmharbor help >/dev/null + + - name: Check Windows PowerShell entry point + if: runner.os == 'Windows' + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + foreach ($file in @('bin/llmharbor.ps1', 'install.ps1')) { + [void][scriptblock]::Create((Get-Content -LiteralPath $file -Raw)) + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File bin/llmharbor.ps1 help *> $null + if ($LASTEXITCODE -ne 0) { throw "llmharbor help exited with $LASTEXITCODE" } + # Windows PowerShell 5.1 promotes native stderr to NativeCommandError + # under Stop. This command is expected to write an error and fail, so + # capture its process exit code without weakening the other checks. + $ErrorActionPreference = 'SilentlyContinue' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File bin/llmharbor.ps1 definitely-not-a-command *> $null + $unknownCommandExit = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + if ($unknownCommandExit -eq 0) { throw 'An unknown command must return a non-zero exit code' } + exit 0 # Do not propagate the intentionally failing child process code. + test: - name: Test & build + name: Node ${{ matrix.node-version }} runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ['22.12.0', '24'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci + + - name: Lint dashboard + run: npm run lint - name: Run tests run: npm test - name: Build workspaces run: npm run build + + - name: Validate static website script + if: matrix.node-version == '24' + run: node --check website/script.js + + - name: Audit production dependencies + if: matrix.node-version == '24' + run: npm audit --omit=dev --audit-level=high diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1c811c3..cd63fcd 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,18 +23,21 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload static site - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: website - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/README.md b/README.md index ebc8403..a1bae33 100644 --- a/README.md +++ b/README.md @@ -3,41 +3,30 @@ # LLMHarbor - **Drop one anchor. Route every model.** - - A self-hosted personal API platform for free-tier and local LLM endpoints. Add your provider keys once, mint separate local client keys for every app or agent, and let LLMHarbor route requests across the models that are healthy and allowed. + A self-hosted OpenAI-compatible LLM gateway and control plane for provider APIs, OAuth-backed accounts, local models, and custom endpoints.
Quick start · - Screenshots · API · Providers · Bulk import · - Access policy · + Access policy · Terms of Use · Website · Contributing
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{label}
+404
-Go back to the Playground to test routing, or use the navigation above to choose a dashboard section.
- +404 · Page not found
+Use the navigation to return to the control plane.
+The router is probably still running, but the UI hit a client-side error. Reload the dashboard; if it happens again, copy the message below.
-+{this.state.error.message}diff --git a/client/src/components/harbor-logo.tsx b/client/src/components/harbor-logo.tsx index 1d18c8d..fc281bb 100644 --- a/client/src/components/harbor-logo.tsx +++ b/client/src/components/harbor-logo.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils' export function HarborLogo({ className, showWordmark = false }: { className?: string; showWordmark?: boolean }) { return (-diff --git a/client/src/components/metric-card.tsx b/client/src/components/metric-card.tsx new file mode 100644 index 0000000..2ba8bcc --- /dev/null +++ b/client/src/components/metric-card.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +type MetricTone = 'default' | 'positive' | 'warning' | 'critical' + +const toneClasses: Record= { + default: 'text-foreground', + positive: 'text-emerald-700 dark:text-emerald-400', + warning: 'text-amber-700 dark:text-amber-400', + critical: 'text-destructive', +} + +export function MetricCard({ + label, + value, + detail, + tone = 'default', + className, +}: { + label: string + value: ReactNode + detail?: ReactNode + tone?: MetricTone + className?: string +}) { + return ( + ++ ) +} diff --git a/client/src/components/page-header.tsx b/client/src/components/page-header.tsx index bd8e2e4..3fbbc21 100644 --- a/client/src/components/page-header.tsx +++ b/client/src/components/page-header.tsx @@ -15,65 +15,65 @@ export function PageHeader({ className?: string }) { return ( -{label}
+{value}
+ {detail ?{detail}
: null} ++) } -export function SectionTitle({ title, description, action }: { title: string; description?: string; action?: ReactNode }) { +export function SectionTitle({ id, title, description, action }: { id?: string; title: string; description?: string; action?: ReactNode }) { return (- {eyebrow &&- {actions &&{eyebrow}
} -{title}
+ {eyebrow ?{eyebrow}
: null} +{title}
{description && ({description}
)}{actions}} + {actions ?{actions}: null}) } export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) { return ( --- {action &&{title}
- {description &&{description}
} +{title}
+ {description ?{description}
: null}{action}} + {action ?{action}: null}-++) } export function LoadingState({ title = 'Loading…', description }: { title?: string; description?: string }) { return ( -{title}
- {description &&{description}
} - {action &&{action}} + {description ?{description}
: null} + {action ?{action}: null}-- ++) } export function ErrorState({ title = 'Something went wrong', description, action }: { title?: string; description?: string; action?: ReactNode }) { return ( -+{title}
- {description &&{description}
} + {description ?{description}
: null}-+-+) } diff --git a/client/src/components/status-indicator.tsx b/client/src/components/status-indicator.tsx new file mode 100644 index 0000000..a5012e0 --- /dev/null +++ b/client/src/components/status-indicator.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export type StatusTone = 'neutral' | 'positive' | 'warning' | 'critical' | 'info' + +const dotClasses: Record{title}
- {description &&{description}
} - {action &&{action}} + {description ?{description}
: null} + {action ?{action}: null}= { + neutral: 'bg-muted-foreground/55', + positive: 'bg-emerald-500', + warning: 'bg-amber-500', + critical: 'bg-destructive', + info: 'bg-primary', +} + +const labelClasses: Record = { + neutral: 'text-muted-foreground', + positive: 'text-emerald-700 dark:text-emerald-400', + warning: 'text-amber-700 dark:text-amber-400', + critical: 'text-destructive', + info: 'text-primary', +} + +export function StatusIndicator({ + label, + tone = 'neutral', + className, +}: { + label: string + tone?: StatusTone + className?: string +}) { + return ( + + + {label} + + ) +} + +export function InlineNotice({ + children, + tone = 'neutral', + className, +}: { + children: ReactNode + tone?: StatusTone + className?: string +}) { + const classes: Record = { + neutral: 'border-border bg-muted/40 text-muted-foreground', + positive: 'border-emerald-500/25 bg-emerald-500/8 text-emerald-800 dark:text-emerald-300', + warning: 'border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200', + critical: 'border-destructive/30 bg-destructive/8 text-destructive', + info: 'border-primary/25 bg-primary/8 text-foreground', + } + return ( + + {children} ++ ) +} diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx index 10d4bbe..a2aac00 100644 --- a/client/src/components/ui/badge.tsx +++ b/client/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-[var(--radius-badge)] border border-transparent px-2 py-0.5 text-[11px] font-medium whitespace-nowrap transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", { variants: { variant: { diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx index 99c067e..2d21dc0 100644 --- a/client/src/components/ui/button.tsx +++ b/client/src/components/ui/button.tsx @@ -4,11 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-[var(--radius-button)] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:opacity-70 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-[var(--radius-button)] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color,box-shadow,transform] outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + default: "bg-primary text-primary-foreground shadow-xs [a]:hover:bg-primary/90", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: @@ -21,16 +21,16 @@ const buttonVariants = cva( }, size: { default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[var(--radius-badge)] px-2 text-xs in-data-[slot=button-group]:rounded-[var(--radius-button)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[var(--radius-badge)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-[var(--radius-button)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", + "h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + xs: "h-7 gap-1 rounded-[var(--radius-button)] px-2 text-xs in-data-[slot=button-group]:rounded-[var(--radius-button)] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 rounded-[var(--radius-button)] px-2.5 text-xs in-data-[slot=button-group]:rounded-[var(--radius-button)] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-10 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-9", "icon-xs": "size-6 rounded-[var(--radius-badge)] in-data-[slot=button-group]:rounded-[var(--radius-button)] [&_svg:not([class*='size-'])]:size-3", "icon-sm": - "size-7 rounded-[var(--radius-badge)] in-data-[slot=button-group]:rounded-[var(--radius-button)]", - "icon-lg": "size-9", + "size-8 rounded-[var(--radius-button)] in-data-[slot=button-group]:rounded-[var(--radius-button)]", + "icon-lg": "size-10", }, }, defaultVariants: { diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx index ff78b81..3f66362 100644 --- a/client/src/components/ui/input.tsx +++ b/client/src/components/ui/input.tsx @@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-8 w-full min-w-0 rounded-[var(--radius-input)] border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "h-9 w-full min-w-0 rounded-[var(--radius-input)] border border-input bg-background px-3 py-1.5 text-base transition-[border-color,box-shadow,background-color] outline-none file:mr-3 file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground/80 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/25 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/20 dark:disabled:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className )} {...props} diff --git a/client/src/components/ui/select.tsx b/client/src/components/ui/select.tsx index f2919dc..182923b 100644 --- a/client/src/components/ui/select.tsx +++ b/client/src/components/ui/select.tsx @@ -41,7 +41,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-[var(--radius-input)] border border-input bg-background py-2 pr-2.5 pl-3 text-sm whitespace-nowrap transition-[border-color,box-shadow,background-color] outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/20 dark:hover:bg-input/35 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -83,7 +83,7 @@ function SelectContent({@@ -117,7 +117,7 @@ function SelectItem({ ) { return ( @@ -269,35 +272,60 @@ export default function AnalyticsPage() {) {
) { ) { + + {errorDistLoading ? ( + + ++ ) : !errorDist?.byCategory?.length ? ( + + ) : ( + + {errorDist.byCategory.map(bucket => { + const quotaRelated = bucket.category?.toLowerCase().includes('rate') || bucket.category?.toLowerCase().includes('quota') + return ( ++ )} +++ ) + })} ++ {bucket.count} + {errorDistLoading ? ( -) : !errorDist?.byPlatform?.length ? ( ) : ( + +)}{errorDist.byPlatform.map(item => `${item.platform}: ${item.count} failures`).join('; ')}
- ++ - + + +{errorsLoading ? ( ) : errors.length === 0 ? ( - + ) : (
@@ -306,9 +334,15 @@ export default function AnalyticsPage() { {errors.slice(0, 20).map((e) => ( Provider +Outcome Message Time ))} @@ -317,6 +351,7 @@ export default function AnalyticsPage() { )} + diff --git a/client/src/pages/FallbackPage.tsx b/client/src/pages/FallbackPage.tsx index 1b535cf..db9a2e8 100644 --- a/client/src/pages/FallbackPage.tsx +++ b/client/src/pages/FallbackPage.tsx @@ -1,26 +1,13 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' +import { ArrowDown, ArrowUp } from 'lucide-react' import { apiFetch } from '@/lib/api' +import { formatCompactNumber, formatPercent } from '@/lib/format' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { PageHeader, EmptyState, ErrorState, LoadingState } from '@/components/page-header' +import { MetricCard } from '@/components/metric-card' +import { InlineNotice, StatusIndicator } from '@/components/status-indicator' import { cn } from '@/lib/utils' interface FallbackEntry { @@ -30,6 +17,9 @@ interface FallbackEntry { penalty: number rateLimitHits: number enabled: boolean + modelEnabled: boolean + eligible: boolean + skipReason: string | null platform: string modelId: string displayName: string @@ -40,13 +30,11 @@ interface FallbackEntry { rpdLimit: number | null monthlyTokenBudget: string keyCount: number -} - -function formatTokens(n: number): string { - if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B` - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` - return String(n) + configuredKeyCount: number + enabledKeyCount: number + routeableKeyCount: number + availableKeyCount: number + activeCooldowns: number } interface TokenUsageData { @@ -55,69 +43,70 @@ interface TokenUsageData { models: { displayName: string; platform: string; budget: number }[] } -const platformColors: Record {e.platform} ++ + {e.error} - {new Date(e.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {formatRelativeTime(e.createdAt)} = { - google: '#4285f4', groq: '#f55036', cerebras: '#8b5cf6', sambanova: '#14b8a6', nvidia: '#76b900', - mistral: '#f59e0b', openrouter: '#ec4899', github: '#6e7b8b', cohere: '#d946ef', cloudflare: '#f38020', - zhipu: '#06b6d4', ollama: '#0f766e', kilo: '#7c3aed', pollinations: '#a855f7', llm7: '#0ea5e9', huggingface: '#ff9d00', -} - function TokenUsageBar({ data }: { data: TokenUsageData }) { const { totalBudget, totalUsed, models } = data const remaining = Math.max(0, totalBudget - totalUsed) const remainingPct = totalBudget > 0 ? Math.round((remaining / totalBudget) * 100) : 0 - const modelsWithWidth = models.map(m => ({ - ...m, - remainingTokens: totalBudget > 0 ? (m.budget / totalBudget) * remaining : 0, - widthPct: totalBudget > 0 ? (m.budget / totalBudget) * (remaining / totalBudget) * 100 : 0, - })) - const usedPct = totalBudget > 0 ? (totalUsed / totalBudget) * 100 : 0 + const usedPct = totalBudget > 0 ? Math.min(100, (totalUsed / totalBudget) * 100) : 0 return ( - + --- {formatTokens(remaining)} remaining, {remainingPct}% of {formatTokens(totalBudget)} + {formatCompactNumber(remaining)} remaining · {remainingPct}% of {formatCompactNumber(totalBudget)}Budget remaining
-Monthly token budget
+Quota estimate
+Monthly token budget
- {modelsWithWidth.map((m, i) => )} - {totalUsed > 0 && } ++= 90 ? 'bg-destructive' : usedPct >= 70 ? 'bg-amber-500' : 'bg-primary')} style={{ width: `${usedPct}%` }} />-- {modelsWithWidth.map((m, i) => ( -- - {m.displayName} ++ {models.map(model => ( +++ + {model.displayName} - {formatTokens(m.remainingTokens)} + {formatCompactNumber(model.budget)} budget))}Usage is aggregated across routes; per-model values are configured budgets, not measured balances.
) } -function SortableModelRow({ entry, index, onToggle }: { entry: FallbackEntry; index: number; onToggle: (modelDbId: number, enabled: boolean) => void }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.modelDbId }) - const style = { transform: CSS.Transform.toString(transform), transition } - const color = platformColors[entry.platform] ?? '#94a3b8' +function ModelRow({ entry, index, count, busy, onToggle, onMove }: { entry: FallbackEntry; index: number; count: number; busy: boolean; onToggle: (modelDbId: number, enabled: boolean) => void; onMove: (index: number, direction: -1 | 1) => void }) { + const status = !entry.enabled + ? { label: 'Disabled', tone: 'neutral' as const, reason: 'Skipped by configuration' } + : !entry.modelEnabled + ? { label: 'Disabled', tone: 'neutral' as const, reason: entry.skipReason ?? 'Model is disabled' } + : !entry.eligible + ? { + label: entry.activeCooldowns > 0 ? 'Cooling down' : 'Unavailable', + tone: entry.activeCooldowns > 0 ? 'warning' as const : 'critical' as const, + reason: entry.skipReason ?? 'Not currently eligible for routing', + } + : entry.penalty > 0 + ? { label: 'Degraded', tone: 'warning' as const, reason: `Recent rate-limit pressure adds ${entry.penalty} priority points` } + : { label: 'Ready', tone: 'positive' as const, reason: 'Eligible for routing' } return ( -- -{index + 1}++{index + 1}) } @@ -134,8 +127,8 @@ export default function FallbackPage() { const queryClient = useQueryClient() const [localEntries, setLocalEntries] = useState- {entry.displayName} - {entry.platform} - {entry.penalty > 0 && -{entry.penalty} penalty} + {entry.platform} +- +-+ {status.reason} + {entry.platform}/{entry.modelId} + {entry.availableKeyCount}/{entry.configuredKeyCount} credentials available + Base priority {entry.priority} + Effective priority {entry.effectivePriority} + {entry.activeCooldowns > 0 && {entry.activeCooldowns} cooling down} Intel #{entry.intelligenceRank} Speed #{entry.speedRank} {entry.rpmLimit && {entry.rpmLimit} rpm} @@ -125,7 +114,11 @@ function SortableModelRow({ entry, index, onToggle }: { entry: FallbackEntry; in {entry.monthlyTokenBudget} tok/moonToggle(entry.modelDbId, checked)} aria-label={`${entry.enabled ? 'Disable' : 'Enable'} ${entry.displayName} in routing`} /> + + + ++onToggle(entry.modelDbId, checked)} aria-label={`${entry.enabled ? 'Disable' : 'Enable'} ${entry.displayName} in routing`} /> (null) - const { data: entries = [], isLoading, isError, error, refetch } = useQuery ({ queryKey: ['fallback'], queryFn: () => apiFetch('/api/fallback') }) - const { data: tokenUsage } = useQuery ({ queryKey: ['fallback', 'token-usage'], queryFn: () => apiFetch('/api/fallback/token-usage') }) + const { data: entries = [], isLoading, isError, error, refetch } = useQuery ({ queryKey: ['fallback'], queryFn: ({ signal }) => apiFetch('/api/fallback', { signal }) }) + const { data: tokenUsage } = useQuery ({ queryKey: ['fallback', 'token-usage'], queryFn: ({ signal }) => apiFetch('/api/fallback/token-usage', { signal }) }) const saveMutation = useMutation({ mutationFn: (data: { modelDbId: number; priority: number; enabled: boolean }[]) => apiFetch('/api/fallback', { method: 'PUT', body: JSON.stringify(data) }), @@ -147,20 +140,25 @@ export default function FallbackPage() { }) const allEntries = localEntries ?? entries - const displayEntries = allEntries.filter(e => e.keyCount > 0) - const unconfiguredPlatforms = [...new Set(allEntries.filter(e => e.keyCount === 0).map(e => e.platform))] + const displayEntries = allEntries.filter(e => e.configuredKeyCount > 0) + const unconfiguredPlatforms = [...new Set(allEntries.filter(e => e.configuredKeyCount === 0).map(e => e.platform))] const enabledCount = displayEntries.filter(e => e.enabled).length - const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })) + function reorderVisible(reorderedVisible: FallbackEntry[]) { + let visibleIndex = 0 + setLocalEntries(allEntries + .map(entry => entry.configuredKeyCount > 0 ? (reorderedVisible[visibleIndex++] ?? entry) : entry) + .map((entry, index) => ({ ...entry, priority: index + 1, effectivePriority: index + 1 + entry.penalty }))) + } - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event - if (!over || active.id === over.id) return - const oldIndex = displayEntries.findIndex(e => e.modelDbId === active.id) - const newIndex = displayEntries.findIndex(e => e.modelDbId === over.id) - const reorderedVisible = arrayMove(displayEntries, oldIndex, newIndex) - const unconfigured = allEntries.filter(e => e.keyCount === 0) - setLocalEntries([...reorderedVisible.map((e, i) => ({ ...e, priority: i + 1 })), ...unconfigured.map((e, i) => ({ ...e, priority: reorderedVisible.length + i + 1 }))]) + function handleMove(index: number, direction: -1 | 1) { + const nextIndex = index + direction + if (nextIndex < 0 || nextIndex >= displayEntries.length) return + const reordered = [...displayEntries] + const [entry] = reordered.splice(index, 1) + if (!entry) return + reordered.splice(nextIndex, 0, entry) + reorderVisible(reordered) } function handleToggle(modelDbId: number, enabled: boolean) { @@ -172,34 +170,39 @@ export default function FallbackPage() { saveMutation.mutate(allEntries.map(e => ({ modelDbId: e.modelDbId, priority: e.priority, enabled: e.enabled }))) } + function applyPreset(preset: string) { + if (localEntries && !window.confirm('Discard the unsaved routing edits and apply this preset?')) return + sortMutation.mutate(preset) + } + const hasChanges = localEntries !== null return ( - - - + + + >} /> -{tokenUsage && tokenUsage.totalBudget > 0 &&-Configured models
{displayEntries.length}
-Enabled
{enabledCount}
+Hidden providers
{unconfiguredPlatforms.length}
+ entry.enabled && entry.penalty > 0).length} degraded`} tone={enabledCount > 0 ? 'positive' : 'warning'} /> + } {(saveMutation.isError || sortMutation.isError) && ( - ++ )} {isLoading ? ( @@ -211,24 +214,26 @@ export default function FallbackPage() { ) : ( <>{(saveMutation.error ?? sortMutation.error)?.message ?? 'Could not update routing order.'} - -{hasChanges && ( -Drag rows, or focus a handle and use Space plus arrow keys to reorder.-- +e.modelDbId)} strategy={verticalListSortingStrategy}> - -- {displayEntries.map((entry, index) =>-)} - + Arrows set the stable base order. During rate-limit pressure, the router tries the lowest effective priority first. + {formatPercent(displayEntries.length ? (enabledCount / displayEntries.length) * 100 : 0, 0)} enabled +++ {displayEntries.map((entry, index) =>)} + +diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx index 67f51bb..7cc019a 100644 --- a/client/src/pages/KeysPage.tsx +++ b/client/src/pages/KeysPage.tsx @@ -1,13 +1,17 @@ -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' +import { copyText } from '@/lib/clipboard' +import { formatDateTime, formatRelativeTime } from '@/lib/format' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { PageHeader, SectionTitle, EmptyState, ErrorState, LoadingState } from '@/components/page-header' +import { MetricCard } from '@/components/metric-card' +import { InlineNotice, StatusIndicator, type StatusTone } from '@/components/status-indicator' import { cn } from '@/lib/utils' import type { ApiKey, Platform } from '../../../shared/types' @@ -31,20 +35,22 @@ const BUILT_IN_PLATFORMS: { value: Platform; label: string }[] = [ { value: 'huggingface', label: 'HuggingFace Router' }, ] -const statusDot: Record+)} - {unconfiguredPlatforms.length > 0 &&Unsaved routing changes
++Hidden until you add keys: {unconfiguredPlatforms.join(', ')}
} + {unconfiguredPlatforms.length > 0 ?Catalog providers without enabled credentials are excluded from this routing list: {unconfiguredPlatforms.join(', ')}. : null} > )}= { - healthy: 'bg-emerald-500 ', - rate_limited: 'bg-amber-500 ', - invalid: 'bg-rose-500 ', - error: 'bg-rose-500 ', - unknown: 'bg-muted-foreground/40', -} - const statusLabel: Record = { healthy: 'Healthy', rate_limited: 'Rate-limited', invalid: 'Invalid', error: 'Error', unknown: 'Unchecked', + disabled: 'Disabled', +} + +const statusTone: Record = { + healthy: 'positive', + rate_limited: 'warning', + invalid: 'critical', + error: 'critical', + unknown: 'neutral', + disabled: 'neutral', } interface HealthData { @@ -62,6 +68,10 @@ interface EndpointSummary { custom: boolean modelCount: number keyCount: number + credentialMode: 'api-key' | 'oauth' | 'optional-api-key' + configuredKeyCount: number + enabledKeyCount: number + availableKeyCount: number } interface ClientApiKey { @@ -73,6 +83,22 @@ interface ClientApiKey { createdAt: string lastUsedAt: string | null localEndpointId?: number | null + limits: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null } +} + +interface ClientKeyEditor { + id: number + label: string + rpm: string + rpd: string + tpm: string + tpd: string +} + +interface ConnectionInfo { + splitMode: boolean + dashboard: { host: string; port: number } + publicApi: { host: string; port: number; basePath: '/v1' } } interface ProviderImportTarget { @@ -90,17 +116,65 @@ interface BulkImportResult { skipped: number } +function validateEndpointUrl(value: string) { + if (!value.trim()) return null + try { + const url = new URL(value) + if (!['http:', 'https:'].includes(url.protocol)) return 'Use an http:// or https:// URL.' + if (url.username || url.password) return 'Do not put credentials in the endpoint URL.' + return null + } catch { + return 'Enter a valid absolute URL.' + } +} + +function validateRequiredEndpointUrl(value: string) { + return value.trim() ? validateEndpointUrl(value) : 'Base URL is required.' +} + +const clientLimitLabels: Record<'rpm' | 'rpd' | 'tpm' | 'tpd', string> = { + rpm: 'Requests per minute', + rpd: 'Requests per day', + tpm: 'Tokens per minute', + tpd: 'Tokens per day', +} + +function validateClientLimit(value: string, field: keyof typeof clientLimitLabels) { + if (!value) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 + ? null + : `${clientLimitLabels[field]} must be a positive whole number.` +} + +function connectionBaseUrl(connection: ConnectionInfo | undefined) { + if (!connection || !connection.splitMode) return `${window.location.origin}/v1` + const configuredHost = connection.publicApi.host + const reachableHost = configuredHost === '0.0.0.0' || configuredHost === '::' + ? window.location.hostname + : configuredHost + const unwrappedHost = reachableHost.replace(/^\[(.*)\]$/, '$1') + const urlHost = unwrappedHost.includes(':') ? `[${unwrappedHost}]` : unwrappedHost + return `http://${urlHost}:${connection.publicApi.port}${connection.publicApi.basePath}` +} + function ClientKeysSection() { const queryClient = useQueryClient() const navigate = useNavigate() - const [visibleKeyId, setVisibleKeyId] = useState (null) const [copiedKeyId, setCopiedKeyId] = useState (null) + const [connectionCopied, setConnectionCopied] = useState(false) + const [copyError, setCopyError] = useState (null) const [createdKey, setCreatedKey] = useState (null) const [newKeyLabel, setNewKeyLabel] = useState('') + const [keyEditor, setKeyEditor] = useState (null) const { data: clientKeys = [], isLoading, isError, error, refetch } = useQuery ({ queryKey: ['client-api-keys'], - queryFn: () => apiFetch('/api/settings/api-keys'), + queryFn: ({ signal }) => apiFetch('/api/settings/api-keys', { signal }), + }) + const { data: connection } = useQuery ({ + queryKey: ['connection-info'], + queryFn: ({ signal }) => apiFetch('/api/settings/connection', { signal }), }) const createKey = useMutation({ @@ -111,7 +185,6 @@ function ClientKeysSection() { onSuccess: (key) => { setCreatedKey(key) queryClient.invalidateQueries({ queryKey: ['client-api-keys'] }) - queryClient.invalidateQueries({ queryKey: ['unified-key'] }) setNewKeyLabel('') }, }) @@ -124,79 +197,135 @@ function ClientKeysSection() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['client-api-keys'] }), }) + const updateKey = useMutation({ + mutationFn: (editor: ClientKeyEditor) => apiFetch (`/api/settings/api-keys/${editor.id}`, { + method: 'PATCH', + body: JSON.stringify({ + label: editor.label.trim(), + limits: { + rpm: editor.rpm ? Number(editor.rpm) : null, + rpd: editor.rpd ? Number(editor.rpd) : null, + tpm: editor.tpm ? Number(editor.tpm) : null, + tpd: editor.tpd ? Number(editor.tpd) : null, + }, + }), + }), + onSuccess: () => { + setKeyEditor(null) + queryClient.invalidateQueries({ queryKey: ['client-api-keys'] }) + }, + }) + const deleteClientKey = useMutation({ mutationFn: (id: number) => apiFetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['client-api-keys'] }) - queryClient.invalidateQueries({ queryKey: ['unified-key'] }) }, }) - const baseUrl = import.meta.env.DEV - ? `http://${window.location.hostname}:${__SERVER_PORT__}/v1` - : `${window.location.origin}/v1` + const baseUrl = connectionBaseUrl(connection) + + function editClientKey(key: ClientApiKey) { + setKeyEditor({ + id: key.id, + label: key.label, + rpm: key.limits.rpm?.toString() ?? '', + rpd: key.limits.rpd?.toString() ?? '', + tpm: key.limits.tpm?.toString() ?? '', + tpd: key.limits.tpd?.toString() ?? '', + }) + } + + function validClientKeyEditor(editor: ClientKeyEditor) { + return editor.label.trim().length > 0 + && (['rpm', 'rpd', 'tpm', 'tpd'] as const).every(field => !validateClientLimit(editor[field], field)) + } - function copy(key: ClientApiKey) { + async function copyCreatedKey(key: ClientApiKey) { if (!key.key) return - navigator.clipboard.writeText(key.key) - setCopiedKeyId(key.id) - setTimeout(() => setCopiedKeyId(null), 1500) + try { + await copyText(key.key) + setCopyError(null) + setCopiedKeyId(key.id) + window.setTimeout(() => setCopiedKeyId(null), 1500) + } catch (error) { + setCopyError(error instanceof Error ? error.message : 'Could not copy the key.') + } + } + + async function copyBaseUrl() { + try { + await copyText(baseUrl) + setCopyError(null) + setConnectionCopied(true) + window.setTimeout(() => setConnectionCopied(false), 1500) + } catch (error) { + setCopyError(error instanceof Error ? error.message : 'Could not copy the base URL.') + } } return ( - + --Personal API platform
-Client API keys
+Local API access
+Client API keys
Create one OpenAI-compatible key per app, agent, laptop, or experiment. Then tune routes, provider endpoints, and models per key.
--- - ++-- Use Access policy to block Antigravity, allow one provider endpoint, or hide a single model from a specific client key. + ++ Use Access policy to restrict routes, providers, or individual models for each client.-- Base URL -{baseUrl}++-{connection?.splitMode ? 'Public API base URL' : 'Base URL'}+{baseUrl}+{createdKey?.key && ( -Endpoint/v1/chat/completions+)}+Copy this new key now. It will only be shown once.
-{createdKey.key}+{createdKey.key}++ +-{copiedKeyId ? 'Client key copied to clipboard.' : ''}+ {copyError ?{copyError} : null}+ {createKey.isError || toggleKey.isError || updateKey.isError || deleteClientKey.isError ? ( +{(createKey.error ?? toggleKey.error ?? updateKey.error ?? deleteClientKey.error)?.message ?? 'Could not update the client key.'} + ) : null} {isLoading ? () : isError ? ( @@ -204,31 +333,69 @@ function ClientKeysSection() { ) : clientKeys.length === 0 ? ( ) : clientKeys.map((key) => ( - +@@ -236,25 +403,17 @@ function ClientKeysSection() { ) } -function StatCard({ label, value, tone = 'default' }: { label: string; value: string | number; tone?: 'default' | 'good' | 'warn' | 'bad' }) { - const color = tone === 'good' ? 'text-emerald-600 dark:text-emerald-300' : tone === 'warn' ? 'text-amber-600 dark:text-amber-300' : tone === 'bad' ? 'text-rose-600 dark:text-rose-300' : 'text-foreground' - return ( -))}--{key.label}
- - {key.enabled ? 'Enabled' : 'Disabled'} - - {key.lastUsedAt && Last used {new Date(key.lastUsedAt).toLocaleString()}} +- {visibleKeyId === key.id && key.key ? key.key : key.maskedKey} -+{key.maskedKey}+Created {formatDateTime(key.createdAt)} · Last used {formatRelativeTime(key.lastUsedAt)}
++ Limits: {key.limits.rpm ? `${key.limits.rpm} RPM` : 'RPM unlimited'} · {key.limits.rpd ? `${key.limits.rpd} RPD` : 'RPD unlimited'} · {key.limits.tpm ? `${key.limits.tpm} TPM` : 'TPM unlimited'} · {key.limits.tpd ? `${key.limits.tpd} TPD` : 'TPD unlimited'} +
- - - + +toggleKey.mutate({ id: key.id, enabled })} aria-label={`${key.enabled ? 'Disable' : 'Enable'} client key ${key.label}`} /> - Policy is isolated to this key. Other local apps keep their own route, provider, and model permissions. -+ {keyEditor?.id === key.id ? ( + + ) : null}-- ) -} - export default function KeysPage() { const queryClient = useQueryClient() + const navigate = useNavigate() const [platform, setPlatform] = useState{label}
-{value}
-('') const [apiKey, setApiKey] = useState('') const [accountId, setAccountId] = useState('') const [label, setLabel] = useState('') const [endpointName, setEndpointName] = useState('') const [endpointBaseUrl, setEndpointBaseUrl] = useState('') - const [importProviderId, setImportProviderId] = useState('') + const [endpointEditor, setEndpointEditor] = useState<{ platform: string; name: string; baseUrl: string } | null>(null) + const [importPlatform, setImportPlatform] = useState('') const [importLabelPrefix, setImportLabelPrefix] = useState('') const [importContents, setImportContents] = useState('') const [importFileName, setImportFileName] = useState('') @@ -263,26 +422,31 @@ export default function KeysPage() { const { data: keys = [], isLoading, isError, error, refetch } = useQuery ({ queryKey: ['keys'], - queryFn: () => apiFetch('/api/keys'), + queryFn: ({ signal }) => apiFetch('/api/keys', { signal }), }) - const { data: healthData } = useQuery ({ + const { data: healthData, isError: healthError, error: healthQueryError } = useQuery ({ queryKey: ['health'], - queryFn: () => apiFetch('/api/health'), + queryFn: ({ signal }) => apiFetch('/api/health', { signal }), refetchInterval: 30000, }) - const { data: endpoints = [] } = useQuery ({ + const { data: endpoints = [], isError: endpointsError, error: endpointsQueryError, refetch: refetchEndpoints } = useQuery ({ queryKey: ['custom-endpoints'], - queryFn: () => apiFetch('/api/endpoints'), + queryFn: ({ signal }) => apiFetch('/api/endpoints', { signal }), }) - const { data: providerTargets = [] } = useQuery ({ + const { data: providerTargets = [], isError: providerTargetsError, error: providerTargetsQueryError, refetch: refetchProviderTargets } = useQuery ({ queryKey: ['key-import-providers'], - queryFn: () => apiFetch('/api/keys/providers'), + queryFn: ({ signal }) => apiFetch('/api/keys/providers', { signal }), }) const customEndpoints = endpoints.filter(endpoint => endpoint.custom) + const endpointUrlError = useMemo(() => validateEndpointUrl(endpointBaseUrl), [endpointBaseUrl]) + const importLineCount = useMemo( + () => importContents.split(/\r?\n/).filter(line => line.trim() && !line.trim().startsWith('#')).length, + [importContents], + ) const addEndpoint = useMutation({ mutationFn: (body: { name: string; baseUrl: string }) => @@ -305,6 +469,33 @@ export default function KeysPage() { }, }) + const toggleEndpoint = useMutation({ + mutationFn: ({ endpointPlatform, enabled }: { endpointPlatform: string; enabled: boolean }) => + apiFetch(`/api/endpoints/${encodeURIComponent(endpointPlatform)}`, { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['free-model-updater-providers'] }) + }, + }) + + const updateEndpoint = useMutation({ + mutationFn: (editor: { platform: string; name: string; baseUrl: string }) => + apiFetch(`/api/endpoints/${encodeURIComponent(editor.platform)}`, { + method: 'PATCH', + body: JSON.stringify({ name: editor.name.trim(), baseUrl: editor.baseUrl.trim() }), + }), + onSuccess: () => { + setEndpointEditor(null) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) + queryClient.invalidateQueries({ queryKey: ['key-import-providers'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + }, + }) + const addKey = useMutation({ mutationFn: (body: { platform: string; key: string; label?: string }) => apiFetch('/api/keys', { method: 'POST', body: JSON.stringify(body) }), @@ -312,6 +503,7 @@ export default function KeysPage() { queryClient.invalidateQueries({ queryKey: ['keys'] }) queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) setPlatform('') setApiKey('') setAccountId('') @@ -324,14 +516,18 @@ export default function KeysPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['keys'] }) queryClient.invalidateQueries({ queryKey: ['health'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) }, }) const checkAll = useMutation({ - mutationFn: () => apiFetch('/api/health/check-all', { method: 'POST' }), + mutationFn: () => apiFetch('/api/health/check-all', { method: 'POST', timeoutMs: 300_000 }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['keys'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) }, }) @@ -340,6 +536,8 @@ export default function KeysPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['keys'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) }, }) @@ -353,6 +551,22 @@ export default function KeysPage() { queryClient.invalidateQueries({ queryKey: ['keys'] }) queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) + }, + }) + + const toggleProviderKey = useMutation({ + mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => + apiFetch(`/api/keys/${id}`, { + method: 'PATCH', + body: JSON.stringify({ enabled }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['keys'] }) + queryClient.invalidateQueries({ queryKey: ['oauth-accounts'] }) + queryClient.invalidateQueries({ queryKey: ['health'] }) + queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) }, }) @@ -360,7 +574,7 @@ export default function KeysPage() { mutationFn: () => apiFetch ('/api/keys/import', { method: 'POST', body: JSON.stringify({ - providerId: Number(importProviderId), + platform: importPlatform, contents: importContents, labelPrefix: importLabelPrefix || undefined, }), @@ -373,11 +587,14 @@ export default function KeysPage() { queryClient.invalidateQueries({ queryKey: ['keys'] }) queryClient.invalidateQueries({ queryKey: ['health'] }) queryClient.invalidateQueries({ queryKey: ['fallback'] }) + queryClient.invalidateQueries({ queryKey: ['custom-endpoints'] }) }, }) const allPlatforms = endpoints.length > 0 - ? endpoints.map(endpoint => ({ value: endpoint.platform as Platform, label: endpoint.custom ? `${endpoint.name} (custom)` : endpoint.name })) + ? endpoints + .filter(endpoint => endpoint.credentialMode !== 'oauth') + .map(endpoint => ({ value: endpoint.platform as Platform, label: endpoint.custom ? `${endpoint.name} (custom)` : endpoint.name })) : BUILT_IN_PLATFORMS const needsAccountId = platform === 'cloudflare' @@ -396,13 +613,14 @@ export default function KeysPage() { setImportContents(await file.text()) } - const selectedImportTarget = providerTargets.find(target => String(target.providerId) === importProviderId) + const selectedImportTarget = providerTargets.find(target => target.platform === importPlatform) const healthKeyMap = new Map () for (const k of healthData?.keys ?? []) healthKeyMap.set(k.id, k) const grouped = allPlatforms.map(p => ({ ...p, + endpoint: endpoints.find(endpoint => endpoint.platform === p.value), keys: keys.filter(k => k.platform === p.value), })).filter(p => p.keys.length > 0) @@ -413,9 +631,9 @@ export default function KeysPage() { return ( 0 && (