feat: full model addresses with copy, searchable picker, connect card
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:
2026-10-03 16:34:18 +02:00
parent c37fb77be3
commit a845aaa614
10 changed files with 268 additions and 64 deletions

View 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>
)
}

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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">&lt;provider&gt;/&lt;model&gt;</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/&lt;model&gt;</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)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,