feat: full model addresses with copy, searchable picker, connect card
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Model catalog is a table keyed by the exact provider/slug address with a copy button. The chat model picker filters by address and name. API Keys shows the base URL and a curl example, also right after a key is created. /v1/models entries gain created, display_name and context_length.
This commit is contained in:
111
backend/dashboard/src/components/ModelPicker.tsx
Normal file
111
backend/dashboard/src/components/ModelPicker.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CatalogEntry } from '../lib/types'
|
||||
import { fmtContext } from '../lib/format'
|
||||
import { CopyIconButton } from './ui'
|
||||
|
||||
interface Props {
|
||||
models: CatalogEntry[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable combobox for model addresses. Typing filters by address and
|
||||
* display name across every provider. Arrow keys move, Enter picks, Escape closes.
|
||||
*/
|
||||
export default function ModelPicker({ models, value, onChange, disabled }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const list = q ? models.filter(m => `${m.id} ${m.display_name ?? ''}`.toLowerCase().includes(q)) : models
|
||||
return list.slice(0, 200)
|
||||
}, [models, query])
|
||||
|
||||
useEffect(() => { setActive(0) }, [query, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => { if (!wrapRef.current?.contains(e.target as Node)) setOpen(false) }
|
||||
window.addEventListener('mousedown', onDown)
|
||||
return () => window.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active])
|
||||
|
||||
function pick(id: string) {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
function onKey(e: React.KeyboardEvent) {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { setOpen(true); e.preventDefault(); return }
|
||||
if (!open) return
|
||||
if (e.key === 'ArrowDown') { setActive(a => Math.min(a + 1, filtered.length - 1)); e.preventDefault() }
|
||||
else if (e.key === 'ArrowUp') { setActive(a => Math.max(a - 1, 0)); e.preventDefault() }
|
||||
else if (e.key === 'Enter') { if (filtered[active]) pick(filtered[active].id); e.preventDefault() }
|
||||
else if (e.key === 'Escape') { setOpen(false); setQuery('') }
|
||||
}
|
||||
|
||||
const current = models.find(m => m.id === value)
|
||||
let lastProvider = ''
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative flex-1 min-w-[240px]">
|
||||
<div className="input flex items-center gap-2 py-[6px]" style={{ opacity: disabled ? 0.5 : 1 }}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" className="text-ink-4 shrink-0" aria-hidden>
|
||||
<circle cx="6.5" cy="6.5" r="4.5" /><line x1="10.5" y1="10.5" x2="14" y2="14" />
|
||||
</svg>
|
||||
<input
|
||||
role="combobox" aria-expanded={open} aria-controls="model-listbox" aria-label="Model"
|
||||
className="mono bg-transparent border-none outline-none text-[13px] text-ink flex-1 min-w-0 placeholder:text-ink-4"
|
||||
value={open ? query : value}
|
||||
placeholder={models.length ? 'Search models…' : 'No models available'}
|
||||
disabled={disabled || models.length === 0}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
{current?.context_length ? <span className="text-[10.5px] text-ink-4 shrink-0 hide-sm">{fmtContext(current.context_length)}</span> : null}
|
||||
{value && <CopyIconButton text={value} title="Copy model address" />}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div ref={listRef} id="model-listbox" role="listbox"
|
||||
className="absolute left-0 right-0 mt-1 max-h-[340px] overflow-y-auto rounded-lg z-40 py-1"
|
||||
style={{ background: 'var(--color-card-2)', border: '1px solid var(--color-line-2)', boxShadow: '0 10px 30px rgba(0,0,0,0.5)' }}>
|
||||
{filtered.length === 0 && <div className="px-3 py-2 text-xs text-ink-3">No models match "{query}".</div>}
|
||||
{filtered.map((m, i) => {
|
||||
const header = m.owned_by !== lastProvider
|
||||
lastProvider = m.owned_by
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{header && <div className="label px-3 pt-2 pb-1">{m.owned_by}</div>}
|
||||
<div role="option" aria-selected={m.id === value} data-idx={i}
|
||||
onMouseDown={e => { e.preventDefault(); pick(m.id) }}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className="px-3 py-[6px] cursor-pointer flex items-center gap-3"
|
||||
style={{ background: i === active ? 'rgba(124,58,237,0.16)' : 'transparent' }}>
|
||||
<span className="mono text-xs ellip flex-1" style={{ color: m.id === value ? 'var(--color-accent-3)' : 'var(--color-ink)' }}>{m.id}</span>
|
||||
{m.display_name && <span className="text-[11px] text-ink-3 ellip max-w-[40%] hide-sm">{m.display_name}</span>}
|
||||
{m.context_length ? <span className="text-[10.5px] text-ink-4 shrink-0 hide-sm">{fmtContext(m.context_length)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{models.length > filtered.length && query === '' && (
|
||||
<div className="px-3 py-2 text-[10.5px] text-ink-4">Showing {filtered.length} of {models.length}. Type to search.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -263,16 +263,48 @@ export function ConfirmDialog({ title, body, confirmLabel = 'Confirm', danger, o
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Copy button ── */
|
||||
/* ── Copy ── */
|
||||
|
||||
export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
|
||||
export function useCopy(resetMs = 1500): [boolean, (text: string) => Promise<void>] {
|
||||
const [done, setDone] = useState(false)
|
||||
async function copy() {
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setDone(true)
|
||||
setTimeout(() => setDone(false), 1500)
|
||||
setTimeout(() => setDone(false), resetMs)
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
return <Button size="sm" onClick={copy}>{done ? 'Copied' : label}</Button>
|
||||
}, [resetMs])
|
||||
return [done, copy]
|
||||
}
|
||||
|
||||
/** Small icon-only copy button. Stops click propagation so it works inside clickable rows. */
|
||||
export function CopyIconButton({ text, title = 'Copy', className = '' }: { text: string; title?: string; className?: string }) {
|
||||
const [done, copy] = useCopy()
|
||||
return (
|
||||
<button type="button" className={`btn-icon ${className}`} title={done ? 'Copied' : title} aria-label={title}
|
||||
style={{ width: 24, height: 24, color: done ? 'var(--color-good-2)' : undefined }}
|
||||
onClick={e => { e.stopPropagation(); e.preventDefault(); copy(text) }}>
|
||||
{done
|
||||
? <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden><polyline points="2,6.5 5,9.5 10,3" /></svg>
|
||||
: <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden><rect x="4" y="4" width="7" height="7" rx="1.2" /><path d="M8 4V2.5A1.5 1.5 0 0 0 6.5 1h-4A1.5 1.5 0 0 0 1 2.5v4A1.5 1.5 0 0 0 2.5 8H4" /></svg>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A mono code line with a copy button, for addresses and commands. */
|
||||
export function CodeLine({ text, label }: { text: string; label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-[7px] px-3 py-2" style={{ background: 'rgba(0,0,0,0.3)', border: '1px solid var(--color-line)' }}>
|
||||
{label && <span className="label shrink-0">{label}</span>}
|
||||
<code className="mono text-xs text-accent-3 flex-1 min-w-0 whitespace-pre-wrap break-all">{text}</code>
|
||||
<CopyIconButton text={text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Copy button ── */
|
||||
|
||||
export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
|
||||
const [done, copy] = useCopy()
|
||||
return <Button size="sm" onClick={() => copy(text)}>{done ? 'Copied' : label}</Button>
|
||||
}
|
||||
|
||||
@@ -106,7 +106,10 @@ export interface CurrentUser {
|
||||
export interface CatalogEntry {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
owned_by: string
|
||||
display_name?: string
|
||||
context_length?: number
|
||||
}
|
||||
|
||||
export interface CreateProviderParams {
|
||||
|
||||
@@ -4,10 +4,40 @@ import { api } from '../lib/api'
|
||||
import type { VirtualKey } from '../lib/types'
|
||||
import { ago, fmtDate } from '../lib/format'
|
||||
import {
|
||||
Badge, Button, Card, Checkbox, ConfirmDialog, CopyButton, Dot, Empty, ErrorNote, Field, IconButton, Input,
|
||||
Badge, Button, Card, CardHead, Checkbox, CodeLine, ConfirmDialog, CopyButton, Dot, Empty, ErrorNote, Field, IconButton, Input,
|
||||
Loading, Modal, TableHead, Textarea, useToast,
|
||||
} from '../components/ui'
|
||||
|
||||
const BASE_URL = `${window.location.origin}/v1`
|
||||
|
||||
function curlExample(key: string) {
|
||||
return `curl ${BASE_URL}/chat/completions \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"model": "<provider>/<model>", "messages": [{"role": "user", "content": "Hello"}]}'`
|
||||
}
|
||||
|
||||
function ConnectCard() {
|
||||
const [open, setOpen] = useState(() => localStorage.getItem('keys_connect_open') !== '0')
|
||||
useEffect(() => { localStorage.setItem('keys_connect_open', open ? '1' : '0') }, [open])
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHead title="Connect a client" sub="Any OpenAI-compatible SDK works. Point it at the base URL and use a key from this page."
|
||||
right={<Button size="sm" onClick={() => setOpen(o => !o)} aria-expanded={open}>{open ? 'Hide' : 'Show'}</Button>} />
|
||||
{open && (
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<CodeLine label="base url" text={BASE_URL} />
|
||||
<CodeLine text={curlExample('sk-…')} />
|
||||
<p className="text-[11.5px] text-ink-3 leading-relaxed">
|
||||
Model names are <code className="mono text-accent-3"><provider>/<model></code>, exactly as listed by <code className="mono text-accent-3">GET {BASE_URL}/models</code> and in the picker on the Test Chat page.
|
||||
Use <code className="mono text-accent-3">any/<model></code> to let the gateway choose a provider.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const COLS = 'minmax(140px,1.4fr) 120px 100px 100px 100px minmax(120px,1fr) 64px'
|
||||
|
||||
function keyState(k: VirtualKey): { label: string; tone: 'good' | 'neutral' | 'warn' | 'bad' } {
|
||||
@@ -49,7 +79,10 @@ function CreateModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
|
||||
return (
|
||||
<Modal title="Key created" onClose={onClose}>
|
||||
<p className="text-[13px] text-ink-2 mb-3">Copy this key now. It is shown only once and cannot be recovered.</p>
|
||||
<div className="mono text-xs text-accent-3 break-all rounded-lg px-[14px] py-[10px] mb-3" style={{ background: 'rgba(0,0,0,0.3)', border: '1px solid var(--color-line-2)' }}>{newKey}</div>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<CodeLine label="key" text={newKey} />
|
||||
<CodeLine text={curlExample(newKey)} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<CopyButton text={newKey} label="Copy key" />
|
||||
<Button variant="primary" onClick={onClose}>Done</Button>
|
||||
@@ -168,6 +201,8 @@ export default function ApiKeys() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConnectCard />
|
||||
|
||||
{showCreate && <CreateModal onClose={() => setShowCreate(false)} onCreated={load} />}
|
||||
{revoking && (
|
||||
<ConfirmDialog title="Revoke key" danger confirmLabel="Revoke" onClose={() => setRevoking(null)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { api, authHeaders, beginLogin, errorMessage } from '../lib/api'
|
||||
import type { CatalogEntry } from '../lib/types'
|
||||
import { fmtMs } from '../lib/format'
|
||||
import { Button, ErrorNote, Select } from '../components/ui'
|
||||
import { Button, ErrorNote } from '../components/ui'
|
||||
import ModelPicker from '../components/ModelPicker'
|
||||
|
||||
interface Stats {
|
||||
promptTokens: number
|
||||
@@ -51,16 +52,6 @@ export default function Chat() {
|
||||
useEffect(() => { localStorage.setItem(SYSTEM_KEY, system) }, [system])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const g = new Map<string, CatalogEntry[]>()
|
||||
for (const m of models) {
|
||||
const list = g.get(m.owned_by) ?? []
|
||||
list.push(m)
|
||||
g.set(m.owned_by, list)
|
||||
}
|
||||
return Array.from(g.entries())
|
||||
}, [models])
|
||||
|
||||
async function send() {
|
||||
const text = input.trim()
|
||||
if (!text || streaming || !model) return
|
||||
@@ -170,14 +161,7 @@ export default function Chat() {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 w-full max-w-[820px] mx-auto">
|
||||
<div className="flex items-center gap-2 mb-3 shrink-0 flex-wrap">
|
||||
<Select value={model} onChange={e => setModel(e.target.value)} disabled={streaming} className="mono flex-1 min-w-[220px]" style={{ width: 'auto' }} aria-label="Model">
|
||||
{models.length === 0 && <option value="">No models available</option>}
|
||||
{grouped.map(([prov, list]) => (
|
||||
<optgroup key={prov} label={prov}>
|
||||
{list.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</Select>
|
||||
<ModelPicker models={models} value={model} onChange={setModel} disabled={streaming} />
|
||||
<Button size="sm" variant={showSystem || system ? 'primary' : 'ghost'} onClick={() => setShowSystem(v => !v)} aria-pressed={showSystem}>System prompt</Button>
|
||||
{messages.length > 0 && <Button size="sm" onClick={() => { setMessages([]); setError('') }} disabled={streaming}>Clear</Button>}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import type { AdminModel, ModelOverride } from '../lib/types'
|
||||
import { ago, fmtContext } from '../lib/format'
|
||||
import { Badge, Button, Card, Checkbox, Empty, ErrorNote, Field, Input, Loading, Modal, SearchBox, Segmented, Select, useToast } from '../components/ui'
|
||||
import { fmtContext, fmtTokens } from '../lib/format'
|
||||
import { Badge, Button, Card, Checkbox, CodeLine, CopyIconButton, Empty, ErrorNote, Field, Input, Loading, Modal, SearchBox, Segmented, Select, TableHead, useToast } from '../components/ui'
|
||||
|
||||
type Filter = 'all' | 'live' | 'offline' | 'blocked' | 'overridden'
|
||||
|
||||
@@ -64,8 +64,9 @@ function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose
|
||||
const discIn = model.disc_input_per_mtok, discOut = model.disc_output_per_mtok
|
||||
|
||||
return (
|
||||
<Modal title={model.display_name || model.slug} sub={`${model.provider_name}/${model.slug}`} onClose={onClose}>
|
||||
<Modal title={model.display_name || model.slug} onClose={onClose}>
|
||||
<form onSubmit={save} className="flex flex-col gap-4">
|
||||
<CodeLine label="address" text={`${model.provider_name}/${model.slug}`} />
|
||||
<div className="text-[11.5px] text-ink-3 leading-relaxed">
|
||||
{discIn != null || discOut != null
|
||||
? <>Provider-reported price: <span className="num text-ink-2">${discIn ?? '?'}</span> in / <span className="num text-ink-2">${discOut ?? '?'}</span> out per 1M tokens. Overrides below replace it. Leave a field empty to use the reported price.</>
|
||||
@@ -93,6 +94,14 @@ function OverrideModal({ model, onClose, onSaved }: { model: AdminModel; onClose
|
||||
)
|
||||
}
|
||||
|
||||
const COLS = 'minmax(280px,2.2fr) minmax(140px,1.1fr) 70px 200px 110px 88px 28px'
|
||||
|
||||
function fmtPrice(v: number | null | undefined) {
|
||||
if (v == null) return '—'
|
||||
if (v === 0) return 'free'
|
||||
return v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`
|
||||
}
|
||||
|
||||
export default function Models() {
|
||||
const toast = useToast()
|
||||
const [params, setParams] = useSearchParams()
|
||||
@@ -153,8 +162,8 @@ export default function Models() {
|
||||
return (
|
||||
<div className="animate-fade-up flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SearchBox value={search} onChange={setSearch} placeholder="Search models…" className="flex-1 min-w-[180px] max-w-[320px]" />
|
||||
<Select value={provider} onChange={e => setProvider(e.target.value)} className="w-auto" style={{ width: 'auto' }}>
|
||||
<SearchBox value={search} onChange={setSearch} placeholder="Search by address or name…" className="flex-1 min-w-[180px] max-w-[320px]" />
|
||||
<Select value={provider} onChange={e => setProvider(e.target.value)} style={{ width: 'auto' }}>
|
||||
<option value="">All providers</option>
|
||||
{providers.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</Select>
|
||||
@@ -168,37 +177,49 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
{loading && <Loading />}
|
||||
{!loading && models.length === 0 && <Card><Empty>No models discovered yet. Add a provider, then sync.</Empty></Card>}
|
||||
{!loading && models.length > 0 && visible.length === 0 && <Card><Empty>No models match these filters.</Empty></Card>}
|
||||
<p className="text-xs text-ink-3 -mt-1">
|
||||
The address column is the exact <code className="mono text-accent-3">model</code> value clients send. Click a row to set prices, limits, or block the model.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-[10px]" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))' }}>
|
||||
{visible.map(m => {
|
||||
const state = m.blocked ? { label: 'blocked', tone: 'bad' as const } : !m.enabled ? { label: 'disabled', tone: 'neutral' as const }
|
||||
: m.available ? { label: 'live', tone: 'good' as const } : { label: 'offline', tone: 'neutral' as const }
|
||||
return (
|
||||
<button key={m.id} onClick={() => setSelected(m)} className="card text-left p-[14px] flex flex-col gap-2 hover:border-line-2 transition-colors"
|
||||
style={{ borderColor: m.blocked ? 'rgba(239,68,68,0.3)' : undefined, opacity: m.available || m.blocked ? 1 : 0.6, borderRadius: 9 }}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="mono text-[12.5px] font-medium text-accent-3 ellip" title={m.slug}>{m.slug}</div>
|
||||
<div className="text-[11px] text-ink-3 ellip mt-[2px]">{m.display_name || <span className="text-ink-4">no display name</span>}</div>
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="table-scroll">
|
||||
<TableHead cols={COLS} labels={['Address', 'Name', 'Context', 'Price in / out per 1M', 'Limits', 'Status', '']} />
|
||||
{loading && <Loading />}
|
||||
{!loading && models.length === 0 && <Empty>No models discovered yet. Add a provider, then sync.</Empty>}
|
||||
{!loading && models.length > 0 && visible.length === 0 && <Empty>No models match these filters.</Empty>}
|
||||
{visible.map(m => {
|
||||
const address = `${m.provider_name}/${m.slug}`
|
||||
const state = m.blocked ? { label: 'blocked', tone: 'bad' as const } : !m.enabled ? { label: 'disabled', tone: 'neutral' as const }
|
||||
: m.available ? { label: 'live', tone: 'good' as const } : { label: 'offline', tone: 'neutral' as const }
|
||||
const inPrice = m.price_input_per_mtok ?? m.disc_input_per_mtok
|
||||
const outPrice = m.price_output_per_mtok ?? m.disc_output_per_mtok
|
||||
const limits = [m.rpm_limit != null ? `${m.rpm_limit} rpm` : null, m.tpm_limit != null ? `${fmtTokens(m.tpm_limit)} tpm` : null].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={m.id} role="button" tabIndex={0} onClick={() => setSelected(m)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(m) } }}
|
||||
className="row row-hover cursor-pointer" style={{ gridTemplateColumns: COLS, opacity: m.available || m.blocked ? 1 : 0.6 }}>
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
<span className="mono text-[12px] text-accent-3 ellip" title={address}>{address}</span>
|
||||
<CopyIconButton text={address} title="Copy model address" />
|
||||
</div>
|
||||
<Badge tone={state.tone}>{state.label}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[10.5px] text-ink-4">
|
||||
<Badge tone="accent">{m.provider_name}</Badge>
|
||||
<span className="flex items-center gap-2">
|
||||
{hasOverride(m) && <span title="Has price or limit overrides" className="text-warn-2">override</span>}
|
||||
{m.context_length ? <span>{fmtContext(m.context_length)}</span> : null}
|
||||
{!m.available && m.last_seen_at && <span title={`Last seen ${new Date(m.last_seen_at).toLocaleString()}`}>seen {ago(m.last_seen_at)}</span>}
|
||||
<span className="text-xs text-ink-2 ellip" title={m.display_name}>{m.display_name || <span className="text-ink-4">—</span>}</span>
|
||||
<span className="num text-[11px] text-ink-3">{m.context_length ? fmtContext(m.context_length).replace(' ctx', '') : '—'}</span>
|
||||
<span className="num text-[11px] text-ink-2 flex items-center gap-2">
|
||||
{fmtPrice(inPrice)} / {fmtPrice(outPrice)}
|
||||
{hasOverride(m) && <Badge tone="warn" title="Admin override in effect">override</Badge>}
|
||||
</span>
|
||||
<span className="num text-[11px] text-ink-3 ellip">{limits || <span className="text-ink-4">—</span>}</span>
|
||||
<Badge tone={state.tone} title={!m.available && m.last_seen_at ? `Last seen ${new Date(m.last_seen_at).toLocaleString()}` : undefined}>{state.label}</Badge>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-ink-4 justify-self-center" aria-hidden>
|
||||
<polyline points="4,2 8,6 4,10" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selected && <OverrideModal model={selected} onClose={() => setSelected(null)} onSaved={() => { toast('Model updated', 'good'); load() }} />}
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,21 @@ func (a *API) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
||||
}
|
||||
|
||||
// modelObject renders a catalog entry in OpenAI model shape. display_name
|
||||
// and context_length are extra fields, in the style of OpenRouter, so UIs can
|
||||
// label models without a second request.
|
||||
func modelObject(e *catalog.Entry) map[string]any {
|
||||
return map[string]any{
|
||||
m := map[string]any{
|
||||
"id": e.Address,
|
||||
"object": "model",
|
||||
"created": e.CreatedAt.Unix(),
|
||||
"owned_by": e.ProviderName,
|
||||
}
|
||||
if e.DisplayName != "" {
|
||||
m["display_name"] = e.DisplayName
|
||||
}
|
||||
if e.ContextLength != nil {
|
||||
m["context_length"] = *e.ContextLength
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ func (c *Cache) Reload(ctx context.Context) error {
|
||||
}
|
||||
e := &Entry{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
ProviderID: r.ProviderID,
|
||||
ProviderName: r.ProviderName,
|
||||
ProviderType: r.ProviderType,
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
// cross-provider "any/<slug>" selector.
|
||||
package catalog
|
||||
|
||||
import "github.com/google/uuid"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Strategy selects among multiple candidates for an "any/<slug>" address (or a
|
||||
// multi-target alias).
|
||||
@@ -22,6 +26,7 @@ const AnyProvider = "any"
|
||||
// effective per-1M-token rate (admin override else discovered else 0).
|
||||
type Entry struct {
|
||||
ID uuid.UUID // provider_models.id
|
||||
CreatedAt time.Time // first discovered
|
||||
ProviderID uuid.UUID
|
||||
ProviderName string
|
||||
ProviderType string
|
||||
|
||||
@@ -70,6 +70,7 @@ func (s *Store) UpsertDiscovered(ctx context.Context, providerID uuid.UUID, sync
|
||||
// resolved (override else discovered else 0).
|
||||
type CatalogRow struct {
|
||||
ID uuid.UUID // provider_models.id
|
||||
CreatedAt time.Time
|
||||
ProviderID uuid.UUID
|
||||
ProviderName string
|
||||
ProviderType string
|
||||
@@ -91,7 +92,7 @@ type CatalogRow struct {
|
||||
// ListCatalog returns every catalog entry belonging to an enabled provider.
|
||||
func (s *Store) ListCatalog(ctx context.Context) ([]CatalogRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT pm.id, pm.provider_id, p.name, p.type, pm.slug,
|
||||
SELECT pm.id, pm.created_at, pm.provider_id, p.name, p.type, pm.slug,
|
||||
COALESCE(pm.display_name, ''),
|
||||
pm.capabilities, pm.context_length,
|
||||
pm.enabled, pm.blocked, pm.available, pm.param_overrides,
|
||||
@@ -111,7 +112,7 @@ func (s *Store) ListCatalog(ctx context.Context) ([]CatalogRow, error) {
|
||||
for rows.Next() {
|
||||
var r CatalogRow
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.ProviderID, &r.ProviderName, &r.ProviderType, &r.Slug,
|
||||
&r.ID, &r.CreatedAt, &r.ProviderID, &r.ProviderName, &r.ProviderType, &r.Slug,
|
||||
&r.DisplayName, &r.Capabilities, &r.ContextLength,
|
||||
&r.Enabled, &r.Blocked, &r.Available, &r.ParamOverrides,
|
||||
&r.InputPricePerMTok, &r.OutputPricePerMTok, &r.CachedPricePerMTok,
|
||||
|
||||
Reference in New Issue
Block a user