diff --git a/crates/proxy/admin-ui/dist/index.html b/crates/proxy/admin-ui/dist/index.html
index 355312d..b82ac94 100644
--- a/crates/proxy/admin-ui/dist/index.html
+++ b/crates/proxy/admin-ui/dist/index.html
@@ -4,7 +4,7 @@
Proxy Admin
-
+PROXY_API_KEYS=my-key`})]})]})]}),ft&&c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16,padding:"8px 12px",background:"var(--warn-dim)",borderLeft:"3px solid var(--warn)",borderRadius:"var(--r)",fontSize:13},children:[c.jsx("span",{children:"Restart the proxy for imported env vars to take effect."}),c.jsx("button",{className:"btn btn-secondary btn-sm",onClick:Z,children:"Dismiss"})]}),c.jsxs("div",{style:{marginBottom:24},children:[c.jsx("div",{className:"section-label",style:{marginBottom:8},children:"Env File"}),c.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[c.jsx("input",{ref:_,type:"file",accept:".env,.anyllm.env,text/plain",style:{display:"none"},onChange:pt}),c.jsx("button",{className:"btn btn-secondary btn-sm",onClick:()=>_.current?.click(),disabled:N.isPending,children:N.isPending?"Importing…":"Import .anyllm.env"}),c.jsx("button",{className:"btn btn-secondary btn-sm",onClick:qt,children:"Export .anyllm.env"})]}),R&&c.jsxs("div",{style:{marginTop:10},children:[c.jsxs("div",{className:"dim",style:{marginBottom:4},children:[R.applied," variable",R.applied!==1?"s":""," imported.",R.warnings.length===0&&" No issues."]}),R.warnings.length>0&&c.jsxs("div",{style:{marginTop:8,padding:"8px 12px",background:"var(--warn-dim)",borderLeft:"3px solid var(--warn)",borderRadius:"var(--r)",fontSize:12},children:[c.jsx("div",{style:{fontWeight:600,marginBottom:4},children:"Warnings"}),R.warnings.map((B,it)=>c.jsxs("div",{className:"mono",style:{fontSize:12},children:[B.line!=null&&c.jsxs("span",{className:"dim",children:["[line ",B.line,"] "]}),B.key&&c.jsxs("span",{children:[B.key,": "]}),B.message]},it))]})]}),V&&c.jsxs("div",{style:{marginTop:10,padding:"8px 12px",background:"var(--err-dim)",borderLeft:"3px solid var(--err)",borderRadius:"var(--r)",fontSize:12},children:[c.jsx("div",{style:{fontWeight:600,marginBottom:4},children:"Import rejected"}),V.hard_errors.map((B,it)=>c.jsx("div",{className:"mono",style:{fontSize:12},children:B},it)),V.warnings.length>0&&c.jsxs(c.Fragment,{children:[c.jsx("div",{style:{fontWeight:600,marginTop:8,marginBottom:4},children:"Warnings (from partial parse)"}),V.warnings.map((B,it)=>c.jsxs("div",{className:"mono",style:{fontSize:12},children:[B.line!=null&&c.jsxs("span",{className:"dim",children:["[line ",B.line,"] "]}),B.message]},it))]})]}),Y&&c.jsxs("div",{style:{marginTop:10,padding:"8px 12px",background:"var(--err-dim)",borderLeft:"3px solid var(--err)",borderRadius:"var(--r)",fontSize:12},children:["Export failed: ",Y]})]}),c.jsx(sl,{loading:o,error:f?.message}),u&&c.jsx("div",{children:u.entries.map(B=>c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:B.key}),c.jsxs("div",{className:"form-row",children:[c.jsx("input",{value:b[B.key]??B.value,onChange:it=>U(vt=>({...vt,[B.key]:it.target.value}))}),c.jsx("button",{className:"btn btn-primary btn-sm",onClick:()=>ut(B.key,B.value),children:"Save"}),c.jsx("button",{className:"btn btn-secondary btn-sm",onClick:()=>E.mutate(B.key),children:"Reset"})]})]},B.key))}),h&&c.jsxs("div",{className:"readonly-section",style:{marginTop:16},children:[c.jsx("div",{className:"section-label",children:"Environment"}),c.jsx("div",{style:{display:"grid",gridTemplateColumns:"220px 1fr",gap:"4px 12px",marginTop:8,fontSize:12},children:Object.entries(h).map(([B,it])=>c.jsxs(G.Fragment,{children:[c.jsx("span",{className:"dim",children:B}),c.jsx("span",{className:"mono",children:it})]},B))})]})]})}const B0={ok:"var(--ok)",warn:"var(--warn)",err:"var(--err)",dim:"var(--text-3)"};function Ev({status:s,pulse:u}){return c.jsx("span",{style:{display:"inline-block",width:7,height:7,borderRadius:"50%",background:B0[s],animation:u?"pulse 2s ease-in-out infinite":void 0,verticalAlign:"middle",marginRight:6}})}function H0(){const{data:s,isLoading:u,error:o}=gv();return c.jsxs("div",{children:[c.jsx(sl,{loading:u,error:o?.message,empty:s?.length===0}),c.jsx("div",{className:"backend-cards",children:s?.map(f=>c.jsxs("div",{className:"card",children:[c.jsxs("div",{className:"card-header",children:[c.jsx("span",{className:"card-name",children:f.name}),c.jsx(Ev,{status:f.status==="ok"?"ok":"err",pulse:f.status==="ok"})]}),c.jsxs("div",{className:"card-body",children:[c.jsx("div",{className:"mono",children:f.model}),c.jsxs("div",{style:{marginTop:6,display:"grid",gridTemplateColumns:"1fr 1fr",gap:4},children:[c.jsx("span",{className:"dim",children:"Requests"}),c.jsx("span",{className:"mono",children:f.requests_total}),c.jsx("span",{className:"dim",children:"P50"}),c.jsxs("span",{className:"mono",children:[f.p50_ms,"ms"]}),c.jsx("span",{className:"dim",children:"P95"}),c.jsxs("span",{className:"mono",children:[f.p95_ms,"ms"]}),c.jsx("span",{className:"dim",children:"Errors"}),c.jsx("span",{className:"mono",style:{color:f.requests_err>0?"var(--err)":void 0},children:f.requests_err})]})]})]},f.name))})]})}function Q0({variant:s}){return c.jsx("span",{className:`badge badge-${s}`,children:s})}function L0({spent:s,limit:u}){if(!u)return c.jsx("span",{className:"dim",children:"—"});const o=Math.min(s/u*100,100),f=o>=95?"danger":o>=80?"warn":"";return c.jsxs("div",{children:[c.jsx("div",{className:"budget-bar",children:c.jsx("div",{className:`budget-bar-fill${f?` ${f}`:""}`,style:{width:`${o}%`}})}),c.jsxs("span",{className:"dim",style:{fontSize:10},children:["$",s.toFixed(4)," / $",u.toFixed(2)]})]})}function Y0({onCreated:s}){const u=a0(),[o,f]=G.useState(""),[h,g]=G.useState(""),[E,N]=G.useState("");function _(){u.mutate({description:o||null,spend_limit:h?Number(h):null,rpm_limit:E?Number(E):null},{onSuccess:b=>{f(""),g(""),N(""),s(b.key)}})}return c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:"Create Key"}),c.jsx("form",{onSubmit:b=>{b.preventDefault(),_()},children:c.jsxs("div",{className:"form-row",style:{flexWrap:"wrap"},children:[c.jsx("input",{placeholder:"Description",value:o,onChange:b=>f(b.target.value)}),c.jsx("input",{placeholder:"Spend limit USD",type:"number",value:h,onChange:b=>g(b.target.value),style:{width:120}}),c.jsx("input",{placeholder:"RPM limit",type:"number",value:E,onChange:b=>N(b.target.value),style:{width:100}}),c.jsx("button",{type:"submit",className:"btn btn-primary",disabled:u.isPending,children:u.isPending?"Creating…":"Create"})]})})]})}function G0({vk:s,onClose:u}){const o=n0(),f=i0(),[h,g]=G.useState(s.description??""),[E,N]=G.useState(s.spend_limit?.toString()??""),[_,b]=G.useState(s.rpm_limit?.toString()??"");function U(){o.mutate({id:s.id,body:{description:h||null,spend_limit:E?Number(E):null,rpm_limit:_?Number(_):null}},{onSuccess:u})}function R(){confirm("Revoke this key?")&&f.mutate(s.id,{onSuccess:u})}return c.jsx("div",{className:"modal-backdrop",onClick:u,children:c.jsxs("div",{className:"modal",onClick:q=>q.stopPropagation(),children:[c.jsxs("div",{className:"modal-title",children:["Edit Key — ",s.key_prefix,"…"]}),c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:"Description"}),c.jsx("input",{value:h,onChange:q=>g(q.target.value),style:{width:"100%"}})]}),c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:"Spend limit (USD)"}),c.jsx("input",{value:E,onChange:q=>N(q.target.value),type:"number",min:"0",step:"0.01"})]}),c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:"RPM limit"}),c.jsx("input",{value:_,onChange:q=>b(q.target.value),type:"number",min:"0"})]}),c.jsxs("div",{className:"form-row",children:[c.jsx("button",{className:"btn btn-primary",onClick:U,children:"Save"}),c.jsx("button",{className:"btn btn-secondary",onClick:u,children:"Cancel"}),c.jsx("button",{className:"btn btn-danger",style:{marginLeft:"auto"},onClick:R,children:"Revoke"})]})]})})}function X0(){const{data:s,isLoading:u,error:o}=l0(),[f,h]=G.useState(null),[g,E]=G.useState(null);return c.jsxs("div",{children:[c.jsx(Y0,{onCreated:h}),f&&c.jsxs("div",{className:"key-result",children:[c.jsx("div",{className:"key-result-label",children:"New key (copy now — not shown again)"}),f]}),c.jsx(sl,{loading:u,error:o?.message,empty:s?.length===0,message:"No keys"}),s&&s.length>0&&c.jsxs("table",{className:"keys-grid",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Prefix"}),c.jsx("th",{children:"Description"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Spend"}),c.jsx("th",{children:"Requests"}),c.jsx("th",{children:"Created"})]})}),c.jsx("tbody",{children:s.map(N=>c.jsxs("tr",{style:{cursor:"pointer"},onClick:()=>E(N),children:[c.jsxs("td",{className:"mono",children:[N.key_prefix,"…"]}),c.jsx("td",{className:"dim",children:N.description??"—"}),c.jsx("td",{children:c.jsx(Q0,{variant:N.status})}),c.jsx("td",{children:c.jsx(L0,{spent:N.total_spend,limit:N.spend_limit})}),c.jsx("td",{className:"mono",children:N.total_requests.toLocaleString()}),c.jsx("td",{className:"mono dim",children:N.created_at.slice(0,10)})]},N.id))})]}),g&&c.jsx(G0,{vk:g,onClose:()=>E(null)},g.id)]})}const Km={openrouter:{text:"Public, no key needed",needsKey:!1},deepinfra:{text:"Public, no key needed",needsKey:!1},ollama:{text:"No key needed (local)",needsKey:!1},configured:{text:"API key required",needsKey:!0},custom:{text:"API key may be required",needsKey:!0}};function K0(){return c.jsx("svg",{width:"12",height:"12",viewBox:"0 0 16 16",fill:"none",style:{verticalAlign:"-1px",marginRight:3},children:c.jsx("path",{d:"M10.5 1a4.5 4.5 0 0 0-4.1 6.35L2 11.75V15h3.25v-2H7v-1.75h1.75L9.65 10.4A4.5 4.5 0 1 0 10.5 1zm1 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2z",fill:"currentColor"})})}function Z0(){const{data:s,isLoading:u,error:o}=f0(),f=o0(),h=d0(),g=h0(),{data:E}=gv(),{data:N}=Sv(),[_,b]=G.useState(""),[U,R]=G.useState(""),[q,V]=G.useState("openai"),[W,Y]=G.useState(""),[I,ft]=G.useState("openrouter"),[yt,ut]=G.useState(""),pt=Km[I]??Km.custom;function qt(){g.mutate({source:I,...I==="custom"?{url:yt}:{}})}return c.jsxs("div",{children:[c.jsxs("div",{style:{marginBottom:20},children:[c.jsx("div",{className:"section-label",style:{marginBottom:8},children:"Discover Models"}),c.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"},children:[c.jsxs("select",{value:I,onChange:Z=>{ft(Z.target.value),g.reset()},children:[c.jsx("option",{value:"openrouter",children:"OpenRouter"}),c.jsx("option",{value:"deepinfra",children:"DeepInfra"}),c.jsx("option",{value:"ollama",children:"Ollama (local)"}),c.jsx("option",{value:"configured",children:"Configured backend"}),c.jsx("option",{value:"custom",children:"Custom URL"})]}),I==="custom"&&c.jsx("input",{placeholder:"https://api.example.com",value:yt,onChange:Z=>ut(Z.target.value),style:{minWidth:220}}),c.jsx("button",{className:"btn btn-secondary",onClick:qt,disabled:g.isPending||I==="custom"&&!yt,children:g.isPending?"Fetching...":"Fetch"}),c.jsxs("span",{className:"dim",style:{fontSize:12},children:[pt.needsKey&&c.jsx(K0,{}),pt.text]})]}),g.isError&&c.jsx("div",{style:{marginTop:8,padding:"6px 10px",background:"var(--err-dim)",borderLeft:"3px solid var(--err)",borderRadius:"var(--r)",fontSize:12},children:g.error.message}),g.data&&g.data.models.length>0&&c.jsxs("div",{style:{marginTop:8},children:[c.jsxs("div",{className:"dim",style:{fontSize:12,marginBottom:4},children:[g.data.models.length," model",g.data.models.length!==1?"s":""," found. Click to populate the form below."]}),c.jsx("div",{style:{maxHeight:200,overflowY:"auto",border:"1px solid var(--border)",borderRadius:"var(--r)",fontSize:12},children:g.data.models.map(Z=>c.jsxs("div",{onClick:()=>R(Z.id),style:{padding:"4px 8px",cursor:"pointer",borderBottom:"1px solid var(--border)",background:U===Z.id?"var(--accent-dim)":void 0},onMouseEnter:B=>{B.target.style.background="var(--surface-2)"},onMouseLeave:B=>{B.target.style.background=U===Z.id?"var(--accent-dim)":""},children:[c.jsx("span",{className:"mono",children:Z.id}),Z.name&&Z.name!==Z.id&&c.jsx("span",{className:"dim",style:{marginLeft:8},children:Z.name})]},Z.id))})]}),g.data&&g.data.models.length===0&&c.jsx("div",{className:"dim",style:{marginTop:8,fontSize:12},children:"No models returned."})]}),c.jsxs("datalist",{id:"backends-list",children:[E?.map(Z=>c.jsx("option",{value:Z.name,children:Z.name},Z.name)),N?.backends.map(Z=>c.jsxs("option",{value:Z.name,children:[Z.name," (managed)"]},`managed-${Z.name}`))]}),c.jsxs("div",{className:"form-group",children:[c.jsx("div",{className:"form-label",children:"Add Model"}),c.jsxs("div",{className:"form-row",style:{flexWrap:"wrap"},children:[c.jsx("input",{placeholder:"Virtual name",value:_,onChange:Z=>b(Z.target.value)}),c.jsx("input",{placeholder:"Model ID",value:U,onChange:Z=>R(Z.target.value)}),c.jsxs("select",{value:q,onChange:Z=>V(Z.target.value),children:[c.jsx("option",{value:"openai",children:"openai"}),c.jsx("option",{value:"anthropic",children:"anthropic"}),c.jsx("option",{value:"gemini",children:"gemini"}),c.jsx("option",{value:"vertex",children:"vertex"}),c.jsx("option",{value:"azure",children:"azure"}),c.jsx("option",{value:"bedrock",children:"bedrock"})]}),c.jsx("input",{placeholder:"Backend (optional)",value:W,onChange:Z=>Y(Z.target.value),list:"backends-list"}),c.jsx("button",{className:"btn btn-primary",onClick:()=>f.mutate({name:_,model:U,provider:q,...W?{backend_name:W}:{}}),disabled:!_||!U||f.isPending,children:"Add"})]})]}),c.jsx(sl,{loading:u,error:o?.message}),s&&c.jsxs("table",{className:"route-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Virtual Name"}),c.jsx("th",{children:"Model"}),c.jsx("th",{children:"Provider"}),c.jsx("th",{children:"Strategy"}),c.jsx("th",{})]})}),c.jsx("tbody",{children:s.models.map(Z=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono",children:Z.name}),c.jsx("td",{className:"mono",children:Z.model}),c.jsx("td",{className:"dim",children:Z.provider}),c.jsx("td",{className:"dim",children:s.routing_strategy}),c.jsx("td",{children:c.jsx("button",{className:"btn btn-danger btn-sm",onClick:()=>h.mutate(Z.name),children:"Remove"})})]},`${Z.name}-${Z.model}`))})]})]})}function w0(){const[s,u]=G.useState(1),{data:o,isLoading:f,error:h}=m0({page:s,page_size:50});return c.jsxs("div",{children:[c.jsx(sl,{loading:f,error:h?.message}),o&&c.jsxs(c.Fragment,{children:[c.jsxs("table",{className:"route-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Time"}),c.jsx("th",{children:"Action"}),c.jsx("th",{children:"Target"}),c.jsx("th",{children:"Detail"}),c.jsx("th",{children:"IP"})]})}),c.jsx("tbody",{children:o.entries.map(g=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono dim",children:g.timestamp.slice(0,19)}),c.jsx("td",{className:"mono",children:g.action}),c.jsxs("td",{className:"dim",children:[g.target_type,g.target_id?` #${g.target_id}`:""]}),c.jsx("td",{className:"dim",style:{maxWidth:300,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:g.detail??"—"}),c.jsx("td",{className:"mono dim",children:g.source_ip??"—"})]},g.id))})]}),c.jsx(jv,{page:s,hasMore:o.has_more,onPrev:()=>u(g=>Math.max(1,g-1)),onNext:()=>u(g=>g+1)})]})]})}function V0({routes:s}){const u=[...s].sort((o,f)=>f.requests_per_min-o.requests_per_min);return c.jsxs("table",{className:"route-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Route"}),c.jsx("th",{children:"Req/min"}),c.jsx("th",{children:"Error rate"}),c.jsx("th",{children:"Avg latency"}),c.jsx("th",{children:"P95 latency"}),c.jsx("th",{children:"Total"})]})}),c.jsx("tbody",{children:u.map(o=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono",children:o.path}),c.jsx("td",{className:"mono",children:o.requests_per_min.toFixed(2)}),c.jsxs("td",{className:"mono",style:{color:o.error_rate>.05?"var(--err)":o.error_rate>.01?"var(--warn)":void 0},children:[(o.error_rate*100).toFixed(1),"%"]}),c.jsxs("td",{className:"mono",children:[o.avg_latency_ms.toFixed(0),"ms"]}),c.jsxs("td",{className:"mono",children:[o.p95_latency_ms,"ms"]}),c.jsx("td",{className:"mono",children:o.total_requests.toLocaleString()})]},o.path))})]})}const Zm=["#e8a030","#d4922b","#c07820","#a86015","#8c500a"],wm=["#6eb5c0","#5aa0ab","#468b96","#327681","#1e616c"];function J0(){const[s,u]=G.useState(6),{data:o,isLoading:f,error:h}=v0(s),g=o?.routes??[],E=g.slice(0,5).map((N,_)=>{const b=(o?.series??[]).filter(U=>U.path===N.path).map(U=>U.requests);return{label:N.path,color:Zm[_%Zm.length],data:b}});return c.jsxs("div",{children:[c.jsxs("div",{className:"section-header",children:[c.jsx("span",{className:"section-label",children:"Traffic"}),c.jsxs("select",{value:s,onChange:N=>u(Number(N.target.value)),children:[c.jsx("option",{value:1,children:"Last 1 hour"}),c.jsx("option",{value:6,children:"Last 6 hours"}),c.jsx("option",{value:24,children:"Last 24 hours"})]})]}),c.jsx(sl,{loading:f,error:h?.message}),o&&c.jsxs(c.Fragment,{children:[c.jsx(V0,{routes:o.routes}),c.jsxs("div",{className:"operator-grid",style:{marginTop:16},children:[c.jsxs("div",{className:"chart-card",children:[c.jsx("div",{className:"chart-header",children:c.jsxs("div",{children:[c.jsx("div",{className:"chart-title",children:"Requests / min by route"}),c.jsx("div",{className:"chart-subtitle",children:"Stacked over time window"})]})}),c.jsx(vs,{series:E})]}),c.jsxs("div",{className:"chart-card",children:[c.jsx("div",{className:"chart-header",children:c.jsxs("div",{children:[c.jsx("div",{className:"chart-title",children:"Avg latency per route"}),c.jsx("div",{className:"chart-subtitle",children:"ms"})]})}),g.length===0?c.jsx("div",{className:"empty",children:"No routes"}):c.jsx("div",{style:{display:"flex",flexDirection:"column",gap:8,paddingTop:8},children:g.slice(0,5).map((N,_)=>{const b=Math.max(...g.slice(0,5).map(R=>R.avg_latency_ms),1),U=N.avg_latency_ms/b*100;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",fontSize:11,marginBottom:2},children:[c.jsx("span",{className:"mono dim",style:{overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"70%"},children:N.path}),c.jsxs("span",{className:"mono",children:[N.avg_latency_ms.toFixed(0),"ms"]})]}),c.jsx("div",{style:{height:6,background:"var(--border)",borderRadius:0},children:c.jsx("div",{style:{height:"100%",width:`${U}%`,background:wm[_%wm.length],borderRadius:0}})})]},N.path)})})]})]})]})]})}function k0(s){const u=Math.floor(Date.now()/1e3-s),o=Math.floor(u/86400),f=Math.floor(u%86400/3600),h=Math.floor(u%3600/60);return o>0?`${o}d ${f}h ${h}m`:f>0?`${f}h ${h}m`:`${h}m`}function F0({proxy:s}){return c.jsxs("div",{className:"uptime-proxy",children:[c.jsxs("div",{className:"uptime-proxy-stats",children:[c.jsxs("div",{children:[c.jsx("div",{className:"section-label",children:"Uptime (30d)"}),c.jsxs("div",{className:"uptime-pct",children:[s.uptime_pct_30d.toFixed(2),"%"]})]}),c.jsxs("div",{children:[c.jsx("div",{className:"section-label",children:"Running"}),c.jsx("div",{className:"stat-value",style:{fontSize:16},children:k0(s.started_at)})]})]}),c.jsx("div",{className:"section-label",style:{marginBottom:4},children:"30-day history"}),c.jsx("div",{className:"history-bar",children:s.history.map(u=>c.jsx("div",{className:`history-day ${u.status}`,title:`${u.date}: ${u.status}`},u.date))})]})}function W0({b:s}){const u=s.status==="up"?"ok":s.status==="down"?"err":"dim",o=s.last_checked_at?new Date(s.last_checked_at*1e3).toLocaleTimeString():"—";return c.jsxs("tr",{children:[c.jsx("td",{className:"mono",children:s.name}),c.jsxs("td",{children:[c.jsx(Ev,{status:u,pulse:s.status==="up"}),s.status]}),c.jsxs("td",{className:"mono",children:[s.uptime_pct_30d.toFixed(2),"%"]}),c.jsx("td",{className:"mono dim",children:o}),c.jsx("td",{className:"mono dim",children:s.last_latency_ms!=null?`${s.last_latency_ms}ms`:"—"}),c.jsx("td",{children:c.jsx("div",{className:"history-bar",style:{height:12},children:s.history.map(f=>c.jsx("div",{className:`history-day ${f.status}`,title:`${f.date}: ${f.status}`},f.date))})})]})}function $0(){const{data:s,isLoading:u,error:o}=y0();return c.jsxs("div",{children:[c.jsx(sl,{loading:u,error:o?.message}),s&&c.jsxs(c.Fragment,{children:[c.jsx(F0,{proxy:s.proxy}),c.jsx("div",{className:"section-label",style:{marginTop:16,marginBottom:8},children:"Backend Availability"}),c.jsxs("table",{className:"backend-health-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Backend"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Uptime (30d)"}),c.jsx("th",{children:"Last checked"}),c.jsx("th",{children:"Latency"}),c.jsx("th",{children:"History"})]})}),c.jsx("tbody",{children:s.backends.slice().sort((f,h)=>f.name.localeCompare(h.name)).map(f=>c.jsx(W0,{b:f},f.name))})]})]})]})}function I0(){const s=Rl(b=>b.token),u=Rl(b=>b.login),o=ma(b=>b.lastEvent),f=Ue(),[h,g]=G.useState("dashboard"),[E,N]=G.useState(!0),{data:_}=Ig(!!s);return G.useEffect(()=>{const U=new URLSearchParams(location.search).get("token");U&&!s?fetch("/admin/api/metrics",{headers:{Authorization:`Bearer ${U}`}}).then(R=>{R.ok&&(u(U),history.replaceState(null,"",location.pathname))}).catch(()=>{}).finally(()=>N(!1)):N(!1)},[]),G.useEffect(()=>{s?Fg():Wg()},[s]),G.useEffect(()=>{s&&_&&!_.configured&&g("settings")},[_?.configured,s]),G.useEffect(()=>{o&&(o.type==="metrics_snapshot"?f.setQueryData(["metrics"],o.data):o.type==="backend_health_changed"&&f.invalidateQueries({queryKey:["uptime"]}))},[o,f]),E?null:s?c.jsxs("div",{children:[c.jsx(_0,{activeTab:h,onTabChange:g}),c.jsxs("div",{className:"tab-content",children:[h==="dashboard"&&c.jsx(M0,{}),h==="requests"&&c.jsx(C0,{}),h==="settings"&&c.jsx(q0,{configured:_?.configured??!0}),h==="backends"&&c.jsx(H0,{}),h==="keys"&&c.jsx(X0,{}),h==="models"&&c.jsx(Z0,{}),h==="audit"&&c.jsx(w0,{}),h==="traffic"&&c.jsx(J0,{}),h==="uptime"&&c.jsx($0,{})]})]}):c.jsx(j0,{})}const P0=new Ag({defaultOptions:{queries:{retry:1,refetchOnWindowFocus:!1}}});ag.createRoot(document.getElementById("root")).render(c.jsx(G.StrictMode,{children:c.jsx(zg,{client:P0,children:c.jsx(I0,{})})}));
diff --git a/crates/proxy/admin-ui/src/api/types.ts b/crates/proxy/admin-ui/src/api/types.ts
index 188b04f..055b16b 100644
--- a/crates/proxy/admin-ui/src/api/types.ts
+++ b/crates/proxy/admin-ui/src/api/types.ts
@@ -243,11 +243,23 @@ export interface EnvImportError {
export interface CatalogProvider {
id: string
- name: string
+ display_name: string
+ name?: string // keep for backwards compat if anything uses it
protocol: string
auth: string
+ status: 'implemented' | 'wired' | 'stub'
default_base_url: string
env_vars: string[]
+ litellm_prefix: string
+ capabilities: {
+ chat_completions: boolean
+ streaming: boolean
+ tool_use: boolean
+ embeddings: boolean
+ vision: boolean
+ batch: boolean
+ }
+ model_count: number
}
// --- Managed backends ---
diff --git a/crates/proxy/admin-ui/src/tabs/settings/BackendForm.tsx b/crates/proxy/admin-ui/src/tabs/settings/BackendForm.tsx
index ca4f01b..81a423a 100644
--- a/crates/proxy/admin-ui/src/tabs/settings/BackendForm.tsx
+++ b/crates/proxy/admin-ui/src/tabs/settings/BackendForm.tsx
@@ -128,9 +128,17 @@ export function BackendForm({ initial, onSuccess, onCancel }: BackendFormProps)
{providers.length === 0 && (
)}
- {providers.map(p => (
-
- ))}
+ {(['implemented', 'wired', 'stub'] as const).map(status => {
+ const group = providers.filter(p => p.status === status)
+ if (group.length === 0) return null
+ return (
+
+ )
+ })}
diff --git a/crates/proxy/src/admin/routes/catalog.rs b/crates/proxy/src/admin/routes/catalog.rs
new file mode 100644
index 0000000..fadad43
--- /dev/null
+++ b/crates/proxy/src/admin/routes/catalog.rs
@@ -0,0 +1,133 @@
+use anyllm_providers::{
+ all_providers, get_provider, list_models,
+ model::ModelStatus,
+ provider::{AuthKind, ProviderProtocol, ProviderStatus},
+};
+use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
+
+fn protocol_str(p: ProviderProtocol) -> &'static str {
+ match p {
+ ProviderProtocol::OpenAICompat => "openai_compat",
+ ProviderProtocol::AzureOpenAI => "azure_openai",
+ ProviderProtocol::VertexAI => "vertex_ai",
+ ProviderProtocol::GeminiOpenAI => "gemini_openai",
+ ProviderProtocol::GeminiNative => "gemini_native",
+ ProviderProtocol::AnthropicNative => "anthropic_native",
+ ProviderProtocol::BedrockNative => "bedrock_native",
+ ProviderProtocol::Custom => "custom",
+ }
+}
+
+fn auth_str(a: AuthKind) -> &'static str {
+ match a {
+ AuthKind::Bearer => "bearer",
+ AuthKind::GoogleApiKey => "google_api_key",
+ AuthKind::AzureApiKey => "azure_api_key",
+ AuthKind::AwsSigV4 => "aws_sigv4",
+ AuthKind::None => "none",
+ }
+}
+
+fn provider_status_str(s: ProviderStatus) -> &'static str {
+ match s {
+ ProviderStatus::Implemented => "implemented",
+ ProviderStatus::Wired => "wired",
+ ProviderStatus::Stub => "stub",
+ }
+}
+
+fn model_status_str(s: ModelStatus) -> &'static str {
+ match s {
+ ModelStatus::Available => "available",
+ ModelStatus::Deprecated => "deprecated",
+ ModelStatus::Stub => "stub",
+ }
+}
+
+/// GET /admin/api/catalog/providers
+///
+/// Returns all registered providers with metadata from the compile-time registry.
+/// No SharedState needed — data is all static.
+pub(super) async fn list_providers() -> impl IntoResponse {
+ let providers: Vec = all_providers()
+ .map(|p| {
+ let model_count = list_models(p.id).len();
+ serde_json::json!({
+ "id": p.id,
+ "display_name": p.display_name,
+ "protocol": protocol_str(p.protocol),
+ "auth": auth_str(p.auth),
+ "status": provider_status_str(p.status),
+ "default_base_url": p.default_base_url,
+ "env_vars": p.env_vars,
+ "litellm_prefix": p.litellm_prefix,
+ "capabilities": {
+ "chat_completions": p.capabilities.chat_completions,
+ "streaming": p.capabilities.streaming,
+ "tool_use": p.capabilities.tool_use,
+ "embeddings": p.capabilities.embeddings,
+ "vision": p.capabilities.vision,
+ "batch": p.capabilities.batch,
+ },
+ "model_count": model_count,
+ })
+ })
+ .collect();
+
+ Json(serde_json::json!({ "providers": providers })).into_response()
+}
+
+/// GET /admin/api/catalog/providers/{id}/models
+///
+/// Returns all static models for the given provider, enriched with pricing data
+/// from the embedded pricing table. Returns 404 for unknown provider ids.
+pub(super) async fn list_provider_models(Path(provider_id): Path) -> impl IntoResponse {
+ if !super::is_safe_model_name(&provider_id) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({ "error": "invalid provider id" })),
+ )
+ .into_response();
+ }
+
+ if get_provider(&provider_id).is_none() {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({ "error": "provider not found" })),
+ )
+ .into_response();
+ }
+
+ let models: Vec = list_models(&provider_id)
+ .iter()
+ .map(|m| {
+ let pricing = crate::cost::price_per_million_for_model(m.id).map(|(inp, out)| {
+ serde_json::json!({
+ "input_per_million_tokens": inp,
+ "output_per_million_tokens": out,
+ })
+ });
+ serde_json::json!({
+ "id": m.id,
+ "context_window": m.context_window,
+ "max_output_tokens": m.max_output_tokens,
+ "status": model_status_str(m.status),
+ "capabilities": {
+ "streaming": m.capabilities.streaming,
+ "tool_use": m.capabilities.tool_use,
+ "vision": m.capabilities.vision,
+ "extended_thinking": m.capabilities.extended_thinking,
+ },
+ "pricing": pricing,
+ })
+ })
+ .collect();
+
+ let has_models = !models.is_empty();
+ Json(serde_json::json!({
+ "provider_id": provider_id,
+ "has_models": has_models,
+ "models": models,
+ }))
+ .into_response()
+}
diff --git a/crates/proxy/src/admin/routes/mod.rs b/crates/proxy/src/admin/routes/mod.rs
index 03d8635..d84e954 100644
--- a/crates/proxy/src/admin/routes/mod.rs
+++ b/crates/proxy/src/admin/routes/mod.rs
@@ -1,6 +1,7 @@
// Admin server routes. Served on a separate localhost-only listener.
pub mod audit;
+pub mod catalog;
pub mod config;
pub mod env;
pub mod keys;
@@ -420,6 +421,11 @@ pub fn admin_router(shared: SharedState, token: Arc>)
"/admin/api/mcp-servers/{name}",
delete(mcp::remove_mcp_server),
)
+ .route("/admin/api/catalog/providers", get(catalog::list_providers))
+ .route(
+ "/admin/api/catalog/providers/:id/models",
+ get(catalog::list_provider_models),
+ )
.route("/admin/api/status", get(status::get_status))
.route("/admin/api/traffic", get(traffic::get_traffic))
.route("/admin/api/uptime", get(uptime::get_uptime))
diff --git a/crates/proxy/src/cost/mod.rs b/crates/proxy/src/cost/mod.rs
index 188217c..8307e9e 100644
--- a/crates/proxy/src/cost/mod.rs
+++ b/crates/proxy/src/cost/mod.rs
@@ -104,6 +104,15 @@ pub fn pricing() -> &'static ModelPricing {
&PRICING
}
+/// Return (input_per_million, output_per_million) for a model, or None if unknown.
+/// Costs are scaled to per-million for human-readable display; the underlying
+/// table stores per-token values.
+pub fn price_per_million_for_model(model_id: &str) -> Option<(f64, f64)> {
+ pricing()
+ .price_for_model(model_id)
+ .map(|(i, o)| (i * 1_000_000.0, o * 1_000_000.0))
+}
+
/// A single pricing record loaded from the JSON pricing table.
/// `model_pattern` supports exact match and longest-prefix matching.
#[derive(Debug, Clone, serde::Deserialize)]