diff --git a/crates/proxy/admin-ui/dist/index.html b/crates/proxy/admin-ui/dist/index.html
index 6a55af5..8a96852 100644
--- a/crates/proxy/admin-ui/dist/index.html
+++ b/crates/proxy/admin-ui/dist/index.html
@@ -4,60 +4,21 @@
Proxy Admin
-
-
+PROXY_API_KEYS=my-key`})]})]})]}),_&&(0,L.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:[(0,L.jsx)(`span`,{children:`Restart the proxy for imported env vars to take effect.`}),(0,L.jsx)(`button`,{className:`btn btn-secondary btn-sm`,onClick:S,children:`Dismiss`})]}),(0,L.jsxs)(`div`,{style:{marginBottom:24},children:[(0,L.jsx)(`div`,{className:`section-label`,style:{marginBottom:8},children:`Env File`}),(0,L.jsxs)(`div`,{style:{display:`flex`,gap:8,alignItems:`center`},children:[(0,L.jsx)(`input`,{ref:c,type:`file`,accept:`.env,.anyllm.env,text/plain`,style:{display:`none`},onChange:b}),(0,L.jsx)(`button`,{className:`btn btn-secondary btn-sm`,onClick:()=>c.current?.click(),disabled:s.isPending,children:s.isPending?`Importing…`:`Import .anyllm.env`}),(0,L.jsx)(`button`,{className:`btn btn-secondary btn-sm`,onClick:x,children:`Export .anyllm.env`})]}),d&&(0,L.jsxs)(`div`,{style:{marginTop:10},children:[(0,L.jsxs)(`div`,{className:`dim`,style:{marginBottom:4},children:[d.applied,` variable`,d.applied===1?``:`s`,` imported.`,d.warnings.length===0&&` No issues.`]}),d.warnings.length>0&&(0,L.jsxs)(`div`,{style:{marginTop:8,padding:`8px 12px`,background:`var(--warn-dim)`,borderLeft:`3px solid var(--warn)`,borderRadius:`var(--r)`,fontSize:12},children:[(0,L.jsx)(`div`,{style:{fontWeight:600,marginBottom:4},children:`Warnings`}),d.warnings.map((e,t)=>(0,L.jsxs)(`div`,{className:`mono`,style:{fontSize:12},children:[e.line!=null&&(0,L.jsxs)(`span`,{className:`dim`,children:[`[line `,e.line,`] `]}),e.key&&(0,L.jsxs)(`span`,{children:[e.key,`: `]}),e.message]},t))]})]}),p&&(0,L.jsxs)(`div`,{style:{marginTop:10,padding:`8px 12px`,background:`var(--err-dim)`,borderLeft:`3px solid var(--err)`,borderRadius:`var(--r)`,fontSize:12},children:[(0,L.jsx)(`div`,{style:{fontWeight:600,marginBottom:4},children:`Import rejected`}),p.hard_errors.map((e,t)=>(0,L.jsx)(`div`,{className:`mono`,style:{fontSize:12},children:e},t)),p.warnings.length>0&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`div`,{style:{fontWeight:600,marginTop:8,marginBottom:4},children:`Warnings (from partial parse)`}),p.warnings.map((e,t)=>(0,L.jsxs)(`div`,{className:`mono`,style:{fontSize:12},children:[e.line!=null&&(0,L.jsxs)(`span`,{className:`dim`,children:[`[line `,e.line,`] `]}),e.message]},t))]})]}),h&&(0,L.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: `,h]})]}),(0,L.jsx)(Br,{loading:n,error:r?.message}),t&&(0,L.jsx)(`div`,{children:t.entries.map(e=>(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:e.key}),(0,L.jsxs)(`div`,{className:`form-row`,children:[(0,L.jsx)(`input`,{value:l[e.key]??e.value,onChange:t=>u(n=>({...n,[e.key]:t.target.value}))}),(0,L.jsx)(`button`,{className:`btn btn-primary btn-sm`,onClick:()=>y(e.key,e.value),children:`Save`}),(0,L.jsx)(`button`,{className:`btn btn-secondary btn-sm`,onClick:()=>o.mutate(e.key),children:`Reset`})]})]},e.key))}),i&&(0,L.jsxs)(`div`,{className:`readonly-section`,style:{marginTop:16},children:[(0,L.jsx)(`div`,{className:`section-label`,children:`Environment`}),(0,L.jsx)(`div`,{style:{display:`grid`,gridTemplateColumns:`220px 1fr`,gap:`4px 12px`,marginTop:8,fontSize:12},children:Object.entries(i).map(([e,t])=>(0,L.jsxs)(I.Fragment,{children:[(0,L.jsx)(`span`,{className:`dim`,children:e}),(0,L.jsx)(`span`,{className:`mono`,children:t})]},e))})]})]})}var Zr={ok:`var(--ok)`,warn:`var(--warn)`,err:`var(--err)`,dim:`var(--text-3)`};function Qr({status:e,pulse:t}){return(0,L.jsx)(`span`,{style:{display:`inline-block`,width:7,height:7,borderRadius:`50%`,background:Zr[e],animation:t?`pulse 2s ease-in-out infinite`:void 0,verticalAlign:`middle`,marginRight:6}})}function $r(){let{data:e,isLoading:t,error:n}=fr();return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Br,{loading:t,error:n?.message,empty:e?.length===0}),(0,L.jsx)(`div`,{className:`backend-cards`,children:e?.map(e=>(0,L.jsxs)(`div`,{className:`card`,children:[(0,L.jsxs)(`div`,{className:`card-header`,children:[(0,L.jsx)(`span`,{className:`card-name`,children:e.name}),(0,L.jsx)(Qr,{status:e.status===`ok`?`ok`:`err`,pulse:e.status===`ok`})]}),(0,L.jsxs)(`div`,{className:`card-body`,children:[(0,L.jsx)(`div`,{className:`mono`,children:e.model}),(0,L.jsxs)(`div`,{style:{marginTop:6,display:`grid`,gridTemplateColumns:`1fr 1fr`,gap:4},children:[(0,L.jsx)(`span`,{className:`dim`,children:`Requests`}),(0,L.jsx)(`span`,{className:`mono`,children:e.requests_total}),(0,L.jsx)(`span`,{className:`dim`,children:`P50`}),(0,L.jsxs)(`span`,{className:`mono`,children:[e.p50_ms,`ms`]}),(0,L.jsx)(`span`,{className:`dim`,children:`P95`}),(0,L.jsxs)(`span`,{className:`mono`,children:[e.p95_ms,`ms`]}),(0,L.jsx)(`span`,{className:`dim`,children:`Errors`}),(0,L.jsx)(`span`,{className:`mono`,style:{color:e.requests_err>0?`var(--err)`:void 0},children:e.requests_err})]})]})]},e.name))})]})}function ei({variant:e}){return(0,L.jsx)(`span`,{className:`badge badge-${e}`,children:e})}function ti({spent:e,limit:t}){if(!t)return(0,L.jsx)(`span`,{className:`dim`,children:`—`});let n=Math.min(e/t*100,100),r=n>=95?`danger`:n>=80?`warn`:``;return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`budget-bar`,children:(0,L.jsx)(`div`,{className:`budget-bar-fill${r?` ${r}`:``}`,style:{width:`${n}%`}})}),(0,L.jsxs)(`span`,{className:`dim`,style:{fontSize:10},children:[`$`,e.toFixed(4),` / $`,t.toFixed(2)]})]})}function ni({onCreated:e}){let t=lr(),[n,r]=(0,I.useState)(``),[i,a]=(0,I.useState)(``),[o,s]=(0,I.useState)(``);function c(){t.mutate({description:n||null,spend_limit:i?Number(i):null,rpm_limit:o?Number(o):null},{onSuccess:t=>{r(``),a(``),s(``),e(t.key)}})}return(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:`Create Key`}),(0,L.jsx)(`form`,{onSubmit:e=>{e.preventDefault(),c()},children:(0,L.jsxs)(`div`,{className:`form-row`,style:{flexWrap:`wrap`},children:[(0,L.jsx)(`input`,{placeholder:`Description`,value:n,onChange:e=>r(e.target.value)}),(0,L.jsx)(`input`,{placeholder:`Spend limit USD`,type:`number`,value:i,onChange:e=>a(e.target.value),style:{width:120}}),(0,L.jsx)(`input`,{placeholder:`RPM limit`,type:`number`,value:o,onChange:e=>s(e.target.value),style:{width:100}}),(0,L.jsx)(`button`,{type:`submit`,className:`btn btn-primary`,disabled:t.isPending,children:t.isPending?`Creating…`:`Create`})]})})]})}function ri({vk:e,onClose:t}){let n=ur(),r=dr(),[i,a]=(0,I.useState)(e.description??``),[o,s]=(0,I.useState)(e.spend_limit?.toString()??``),[c,l]=(0,I.useState)(e.rpm_limit?.toString()??``);function u(){n.mutate({id:e.id,body:{description:i||null,spend_limit:o?Number(o):null,rpm_limit:c?Number(c):null}},{onSuccess:t})}function d(){confirm(`Revoke this key?`)&&r.mutate(e.id,{onSuccess:t})}return(0,L.jsx)(`div`,{className:`modal-backdrop`,onClick:t,children:(0,L.jsxs)(`div`,{className:`modal`,onClick:e=>e.stopPropagation(),children:[(0,L.jsxs)(`div`,{className:`modal-title`,children:[`Edit Key — `,e.key_prefix,`…`]}),(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:`Description`}),(0,L.jsx)(`input`,{value:i,onChange:e=>a(e.target.value),style:{width:`100%`}})]}),(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:`Spend limit (USD)`}),(0,L.jsx)(`input`,{value:o,onChange:e=>s(e.target.value),type:`number`,min:`0`,step:`0.01`})]}),(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:`RPM limit`}),(0,L.jsx)(`input`,{value:c,onChange:e=>l(e.target.value),type:`number`,min:`0`})]}),(0,L.jsxs)(`div`,{className:`form-row`,children:[(0,L.jsx)(`button`,{className:`btn btn-primary`,onClick:u,children:`Save`}),(0,L.jsx)(`button`,{className:`btn btn-secondary`,onClick:t,children:`Cancel`}),(0,L.jsx)(`button`,{className:`btn btn-danger`,style:{marginLeft:`auto`},onClick:d,children:`Revoke`})]})]})})}function ii(){let{data:e,isLoading:t,error:n}=cr(),[r,i]=(0,I.useState)(null),[a,o]=(0,I.useState)(null);return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(ni,{onCreated:i}),r&&(0,L.jsxs)(`div`,{className:`key-result`,children:[(0,L.jsx)(`div`,{className:`key-result-label`,children:`New key (copy now — not shown again)`}),r]}),(0,L.jsx)(Br,{loading:t,error:n?.message,empty:e?.length===0,message:`No keys`}),e&&e.length>0&&(0,L.jsxs)(`table`,{className:`keys-grid`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`th`,{children:`Prefix`}),(0,L.jsx)(`th`,{children:`Description`}),(0,L.jsx)(`th`,{children:`Status`}),(0,L.jsx)(`th`,{children:`Spend`}),(0,L.jsx)(`th`,{children:`Requests`}),(0,L.jsx)(`th`,{children:`Created`})]})}),(0,L.jsx)(`tbody`,{children:e.map(e=>(0,L.jsxs)(`tr`,{style:{cursor:`pointer`},onClick:()=>o(e),children:[(0,L.jsxs)(`td`,{className:`mono`,children:[e.key_prefix,`…`]}),(0,L.jsx)(`td`,{className:`dim`,children:e.description??`—`}),(0,L.jsx)(`td`,{children:(0,L.jsx)(ei,{variant:e.status})}),(0,L.jsx)(`td`,{children:(0,L.jsx)(ti,{spent:e.total_spend,limit:e.spend_limit})}),(0,L.jsx)(`td`,{className:`mono`,children:e.total_requests.toLocaleString()}),(0,L.jsx)(`td`,{className:`mono dim`,children:e.created_at.slice(0,10)})]},e.id))})]}),a&&(0,L.jsx)(ri,{vk:a,onClose:()=>o(null)},a.id)]})}var ai={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 oi(){return(0,L.jsx)(`svg`,{width:`12`,height:`12`,viewBox:`0 0 16 16`,fill:`none`,style:{verticalAlign:`-1px`,marginRight:3},children:(0,L.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 si(){let{data:e,isLoading:t,error:n}=_r(),r=vr(),i=yr(),a=br(),{data:o}=fr(),{data:s}=Er(),[c,l]=(0,I.useState)(``),[u,d]=(0,I.useState)(``),[f,p]=(0,I.useState)(`openai`),[m,h]=(0,I.useState)(``),[g,_]=(0,I.useState)(`openrouter`),[v,y]=(0,I.useState)(``),b=ai[g]??ai.custom;function x(){a.mutate({source:g,...g===`custom`?{url:v}:{}})}return(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{style:{marginBottom:20},children:[(0,L.jsx)(`div`,{className:`section-label`,style:{marginBottom:8},children:`Discover Models`}),(0,L.jsxs)(`div`,{style:{display:`flex`,gap:8,alignItems:`center`,flexWrap:`wrap`},children:[(0,L.jsxs)(`select`,{value:g,onChange:e=>{_(e.target.value),a.reset()},children:[(0,L.jsx)(`option`,{value:`openrouter`,children:`OpenRouter`}),(0,L.jsx)(`option`,{value:`deepinfra`,children:`DeepInfra`}),(0,L.jsx)(`option`,{value:`ollama`,children:`Ollama (local)`}),(0,L.jsx)(`option`,{value:`configured`,children:`Configured backend`}),(0,L.jsx)(`option`,{value:`custom`,children:`Custom URL`})]}),g===`custom`&&(0,L.jsx)(`input`,{placeholder:`https://api.example.com`,value:v,onChange:e=>y(e.target.value),style:{minWidth:220}}),(0,L.jsx)(`button`,{className:`btn btn-secondary`,onClick:x,disabled:a.isPending||g===`custom`&&!v,children:a.isPending?`Fetching...`:`Fetch`}),(0,L.jsxs)(`span`,{className:`dim`,style:{fontSize:12},children:[b.needsKey&&(0,L.jsx)(oi,{}),b.text]})]}),a.isError&&(0,L.jsx)(`div`,{style:{marginTop:8,padding:`6px 10px`,background:`var(--err-dim)`,borderLeft:`3px solid var(--err)`,borderRadius:`var(--r)`,fontSize:12},children:a.error.message}),a.data&&a.data.models.length>0&&(0,L.jsxs)(`div`,{style:{marginTop:8},children:[(0,L.jsxs)(`div`,{className:`dim`,style:{fontSize:12,marginBottom:4},children:[a.data.models.length,` model`,a.data.models.length===1?``:`s`,` found. Click to populate the form below.`]}),(0,L.jsx)(`div`,{style:{maxHeight:200,overflowY:`auto`,border:`1px solid var(--border)`,borderRadius:`var(--r)`,fontSize:12},children:a.data.models.map(e=>(0,L.jsxs)(`div`,{onClick:()=>d(e.id),style:{padding:`4px 8px`,cursor:`pointer`,borderBottom:`1px solid var(--border)`,background:u===e.id?`var(--accent-dim)`:void 0},onMouseEnter:e=>{e.target.style.background=`var(--surface-2)`},onMouseLeave:t=>{t.target.style.background=u===e.id?`var(--accent-dim)`:``},children:[(0,L.jsx)(`span`,{className:`mono`,children:e.id}),e.name&&e.name!==e.id&&(0,L.jsx)(`span`,{className:`dim`,style:{marginLeft:8},children:e.name})]},e.id))})]}),a.data&&a.data.models.length===0&&(0,L.jsx)(`div`,{className:`dim`,style:{marginTop:8,fontSize:12},children:`No models returned.`})]}),(0,L.jsxs)(`datalist`,{id:`backends-list`,children:[o?.map(e=>(0,L.jsx)(`option`,{value:e.name,children:e.name},e.name)),s?.backends.map(e=>(0,L.jsxs)(`option`,{value:e.name,children:[e.name,` (managed)`]},`managed-${e.name}`))]}),(0,L.jsxs)(`div`,{className:`form-group`,children:[(0,L.jsx)(`div`,{className:`form-label`,children:`Add Model`}),(0,L.jsxs)(`div`,{className:`form-row`,style:{flexWrap:`wrap`},children:[(0,L.jsx)(`input`,{placeholder:`Virtual name`,value:c,onChange:e=>l(e.target.value)}),(0,L.jsx)(`input`,{placeholder:`Model ID`,value:u,onChange:e=>d(e.target.value)}),(0,L.jsxs)(`select`,{value:f,onChange:e=>p(e.target.value),children:[(0,L.jsx)(`option`,{value:`openai`,children:`openai`}),(0,L.jsx)(`option`,{value:`anthropic`,children:`anthropic`}),(0,L.jsx)(`option`,{value:`gemini`,children:`gemini`}),(0,L.jsx)(`option`,{value:`vertex`,children:`vertex`}),(0,L.jsx)(`option`,{value:`azure`,children:`azure`}),(0,L.jsx)(`option`,{value:`bedrock`,children:`bedrock`})]}),(0,L.jsx)(`input`,{placeholder:`Backend (optional)`,value:m,onChange:e=>h(e.target.value),list:`backends-list`}),(0,L.jsx)(`button`,{className:`btn btn-primary`,onClick:()=>r.mutate({name:c,model:u,provider:f,...m?{backend_name:m}:{}}),disabled:!c||!u||r.isPending,children:`Add`})]})]}),(0,L.jsx)(Br,{loading:t,error:n?.message}),e&&(0,L.jsxs)(`table`,{className:`route-table`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`th`,{children:`Virtual Name`}),(0,L.jsx)(`th`,{children:`Model`}),(0,L.jsx)(`th`,{children:`Provider`}),(0,L.jsx)(`th`,{children:`Strategy`}),(0,L.jsx)(`th`,{})]})}),(0,L.jsx)(`tbody`,{children:e.models.map(t=>(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`td`,{className:`mono`,children:t.name}),(0,L.jsx)(`td`,{className:`mono`,children:t.model}),(0,L.jsx)(`td`,{className:`dim`,children:t.provider}),(0,L.jsx)(`td`,{className:`dim`,children:e.routing_strategy}),(0,L.jsx)(`td`,{children:(0,L.jsx)(`button`,{className:`btn btn-danger btn-sm`,onClick:()=>i.mutate(t.name),children:`Remove`})})]},`${t.name}-${t.model}`))})]})]})}function ci(){let[e,t]=(0,I.useState)(1),{data:n,isLoading:r,error:i}=xr({page:e,page_size:50});return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Br,{loading:r,error:i?.message}),n&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsxs)(`table`,{className:`route-table`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`th`,{children:`Time`}),(0,L.jsx)(`th`,{children:`Action`}),(0,L.jsx)(`th`,{children:`Target`}),(0,L.jsx)(`th`,{children:`Detail`}),(0,L.jsx)(`th`,{children:`IP`})]})}),(0,L.jsx)(`tbody`,{children:n.entries.map(e=>(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`td`,{className:`mono dim`,children:e.timestamp.slice(0,19)}),(0,L.jsx)(`td`,{className:`mono`,children:e.action}),(0,L.jsxs)(`td`,{className:`dim`,children:[e.target_type,e.target_id?` #${e.target_id}`:``]}),(0,L.jsx)(`td`,{className:`dim`,style:{maxWidth:300,overflow:`hidden`,textOverflow:`ellipsis`,whiteSpace:`nowrap`},children:e.detail??`—`}),(0,L.jsx)(`td`,{className:`mono dim`,children:e.source_ip??`—`})]},e.id))})]}),(0,L.jsx)(Ur,{page:e,hasMore:n.has_more,onPrev:()=>t(e=>Math.max(1,e-1)),onNext:()=>t(e=>e+1)})]})]})}function li({routes:e}){let t=[...e].sort((e,t)=>t.requests_per_min-e.requests_per_min);return(0,L.jsxs)(`table`,{className:`route-table`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`th`,{children:`Route`}),(0,L.jsx)(`th`,{children:`Req/min`}),(0,L.jsx)(`th`,{children:`Error rate`}),(0,L.jsx)(`th`,{children:`Avg latency`}),(0,L.jsx)(`th`,{children:`P95 latency`}),(0,L.jsx)(`th`,{children:`Total`})]})}),(0,L.jsx)(`tbody`,{children:t.map(e=>(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`td`,{className:`mono`,children:e.path}),(0,L.jsx)(`td`,{className:`mono`,children:e.requests_per_min.toFixed(2)}),(0,L.jsxs)(`td`,{className:`mono`,style:{color:e.error_rate>.05?`var(--err)`:e.error_rate>.01?`var(--warn)`:void 0},children:[(e.error_rate*100).toFixed(1),`%`]}),(0,L.jsxs)(`td`,{className:`mono`,children:[e.avg_latency_ms.toFixed(0),`ms`]}),(0,L.jsxs)(`td`,{className:`mono`,children:[e.p95_latency_ms,`ms`]}),(0,L.jsx)(`td`,{className:`mono`,children:e.total_requests.toLocaleString()})]},e.path))})]})}var ui=[`#e8a030`,`#d4922b`,`#c07820`,`#a86015`,`#8c500a`],di=[`#6eb5c0`,`#5aa0ab`,`#468b96`,`#327681`,`#1e616c`];function fi(){let[e,t]=(0,I.useState)(6),{data:n,isLoading:r,error:i}=Sr(e),a=n?.routes??[],o=a.slice(0,5).map((e,t)=>{let r=(n?.series??[]).filter(t=>t.path===e.path).map(e=>e.requests);return{label:e.path,color:ui[t%ui.length],data:r}});return(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{className:`section-header`,children:[(0,L.jsx)(`span`,{className:`section-label`,children:`Traffic`}),(0,L.jsxs)(`select`,{value:e,onChange:e=>t(Number(e.target.value)),children:[(0,L.jsx)(`option`,{value:1,children:`Last 1 hour`}),(0,L.jsx)(`option`,{value:6,children:`Last 6 hours`}),(0,L.jsx)(`option`,{value:24,children:`Last 24 hours`})]})]}),(0,L.jsx)(Br,{loading:r,error:i?.message}),n&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(li,{routes:n.routes}),(0,L.jsxs)(`div`,{className:`operator-grid`,style:{marginTop:16},children:[(0,L.jsxs)(`div`,{className:`chart-card`,children:[(0,L.jsx)(`div`,{className:`chart-header`,children:(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`chart-title`,children:`Requests / min by route`}),(0,L.jsx)(`div`,{className:`chart-subtitle`,children:`Stacked over time window`})]})}),(0,L.jsx)(zr,{series:o})]}),(0,L.jsxs)(`div`,{className:`chart-card`,children:[(0,L.jsx)(`div`,{className:`chart-header`,children:(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`chart-title`,children:`Avg latency per route`}),(0,L.jsx)(`div`,{className:`chart-subtitle`,children:`ms`})]})}),a.length===0?(0,L.jsx)(`div`,{className:`empty`,children:`No routes`}):(0,L.jsx)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:8,paddingTop:8},children:a.slice(0,5).map((e,t)=>{let n=Math.max(...a.slice(0,5).map(e=>e.avg_latency_ms),1),r=e.avg_latency_ms/n*100;return(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{style:{display:`flex`,justifyContent:`space-between`,fontSize:11,marginBottom:2},children:[(0,L.jsx)(`span`,{className:`mono dim`,style:{overflow:`hidden`,textOverflow:`ellipsis`,whiteSpace:`nowrap`,maxWidth:`70%`},children:e.path}),(0,L.jsxs)(`span`,{className:`mono`,children:[e.avg_latency_ms.toFixed(0),`ms`]})]}),(0,L.jsx)(`div`,{style:{height:6,background:`var(--border)`,borderRadius:0},children:(0,L.jsx)(`div`,{style:{height:`100%`,width:`${r}%`,background:di[t%di.length],borderRadius:0}})})]},e.path)})})]})]})]})]})}function pi(e){let t=Math.floor(Date.now()/1e3-e),n=Math.floor(t/86400),r=Math.floor(t%86400/3600),i=Math.floor(t%3600/60);return n>0?`${n}d ${r}h ${i}m`:r>0?`${r}h ${i}m`:`${i}m`}function mi({proxy:e}){return(0,L.jsxs)(`div`,{className:`uptime-proxy`,children:[(0,L.jsxs)(`div`,{className:`uptime-proxy-stats`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`section-label`,children:`Uptime (30d)`}),(0,L.jsxs)(`div`,{className:`uptime-pct`,children:[e.uptime_pct_30d.toFixed(2),`%`]})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`section-label`,children:`Running`}),(0,L.jsx)(`div`,{className:`stat-value`,style:{fontSize:16},children:pi(e.started_at)})]})]}),(0,L.jsx)(`div`,{className:`section-label`,style:{marginBottom:4},children:`30-day history`}),(0,L.jsx)(`div`,{className:`history-bar`,children:e.history.map(e=>(0,L.jsx)(`div`,{className:`history-day ${e.status}`,title:`${e.date}: ${e.status}`},e.date))})]})}function hi({b:e}){let t=e.status===`up`?`ok`:e.status===`down`?`err`:`dim`,n=e.last_checked_at?new Date(e.last_checked_at*1e3).toLocaleTimeString():`—`;return(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`td`,{className:`mono`,children:e.name}),(0,L.jsxs)(`td`,{children:[(0,L.jsx)(Qr,{status:t,pulse:e.status===`up`}),e.status]}),(0,L.jsxs)(`td`,{className:`mono`,children:[e.uptime_pct_30d.toFixed(2),`%`]}),(0,L.jsx)(`td`,{className:`mono dim`,children:n}),(0,L.jsx)(`td`,{className:`mono dim`,children:e.last_latency_ms==null?`—`:`${e.last_latency_ms}ms`}),(0,L.jsx)(`td`,{children:(0,L.jsx)(`div`,{className:`history-bar`,style:{height:12},children:e.history.map(e=>(0,L.jsx)(`div`,{className:`history-day ${e.status}`,title:`${e.date}: ${e.status}`},e.date))})})]})}function gi(){let{data:e,isLoading:t,error:n}=Cr();return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Br,{loading:t,error:n?.message}),e&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(mi,{proxy:e.proxy}),(0,L.jsx)(`div`,{className:`section-label`,style:{marginTop:16,marginBottom:8},children:`Backend Availability`}),(0,L.jsxs)(`table`,{className:`backend-health-table`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsxs)(`tr`,{children:[(0,L.jsx)(`th`,{children:`Backend`}),(0,L.jsx)(`th`,{children:`Status`}),(0,L.jsx)(`th`,{children:`Uptime (30d)`}),(0,L.jsx)(`th`,{children:`Last checked`}),(0,L.jsx)(`th`,{children:`Latency`}),(0,L.jsx)(`th`,{children:`History`})]})}),(0,L.jsx)(`tbody`,{children:e.backends.slice().sort((e,t)=>e.name.localeCompare(t.name)).map(e=>(0,L.jsx)(hi,{b:e},e.name))})]})]})]})}function _i(){let e=Hn(e=>e.token),t=Hn(e=>e.login),n=Un(e=>e.lastEvent),r=hn(),[i,a]=(0,I.useState)(`dashboard`),[o,s]=(0,I.useState)(!0),{data:c}=ir(!!e);return(0,I.useEffect)(()=>{let n=new URLSearchParams(location.search).get(`token`);n&&!e?fetch(`/admin/api/metrics`,{headers:{Authorization:`Bearer ${n}`}}).then(e=>{e.ok&&(t(n),history.replaceState(null,``,location.pathname))}).catch(()=>{}).finally(()=>s(!1)):s(!1)},[]),(0,I.useEffect)(()=>{e?Xn():Zn()},[e]),(0,I.useEffect)(()=>{e&&c&&!c.configured&&a(`settings`)},[c?.configured,e]),(0,I.useEffect)(()=>{n&&(n.type===`metrics_snapshot`?r.setQueryData([`metrics`],n.data):n.type===`backend_health_changed`&&r.invalidateQueries({queryKey:[`uptime`]}))},[n,r]),o?null:e?(0,L.jsxs)(`div`,{children:[(0,L.jsx)(Nr,{activeTab:i,onTabChange:a}),(0,L.jsxs)(`div`,{className:`tab-content`,children:[i===`dashboard`&&(0,L.jsx)(Hr,{}),i===`requests`&&(0,L.jsx)(Wr,{}),i===`settings`&&(0,L.jsx)(Xr,{configured:c?.configured??!0}),i===`backends`&&(0,L.jsx)($r,{}),i===`keys`&&(0,L.jsx)(ii,{}),i===`models`&&(0,L.jsx)(si,{}),i===`audit`&&(0,L.jsx)(ci,{}),i===`traffic`&&(0,L.jsx)(fi,{}),i===`uptime`&&(0,L.jsx)(gi,{})]})]}):(0,L.jsx)(jr,{})}var vi=new dn({defaultOptions:{queries:{retry:1,refetchOnWindowFocus:!1}}});(0,Rn.createRoot)(document.getElementById(`root`)).render((0,L.jsx)(I.StrictMode,{children:(0,L.jsx)(gn,{client:vi,children:(0,L.jsx)(_i,{})})}));
+
diff --git a/crates/proxy/src/config/litellm.rs b/crates/proxy/src/config/litellm.rs
index d030854..94035ca 100644
--- a/crates/proxy/src/config/litellm.rs
+++ b/crates/proxy/src/config/litellm.rs
@@ -258,16 +258,18 @@ pub fn parse_litellm_yaml(yaml: &str) -> LiteLLMParsed {
let (kind, actual_model, stub_provider) = parse_provider_model(&entry.litellm_params.model);
let params = &entry.litellm_params;
- let api_key = params
- .api_key
- .as_deref()
- .map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
- .unwrap_or_else(|| {
- // Fall back to the provider's own env vars when no api_key in YAML.
- stub_provider
- .and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
- .unwrap_or_default()
- });
+ let api_key = super::sanitize_api_key(
+ ¶ms
+ .api_key
+ .as_deref()
+ .map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("model_list api_key: {e}")))
+ .unwrap_or_else(|| {
+ // Fall back to the provider's own env vars when no api_key in YAML.
+ stub_provider
+ .and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
+ .unwrap_or_default()
+ }),
+ );
let base_url = resolve_base_url(&kind, params, stub_provider);
@@ -411,11 +413,11 @@ fn resolve_base_url(
BackendKind::OpenAI => {
// Use the stub provider's default URL when available (e.g. groq, xai, mistral).
// Falls back to OpenAI's URL only when the provider has no default or is unknown.
- stub_provider
+ let url = stub_provider
.map(|p| p.default_base_url)
.filter(|u| !u.is_empty())
- .unwrap_or("https://api.openai.com")
- .to_string()
+ .unwrap_or("https://api.openai.com");
+ super::strip_v1_suffix(url).to_string()
}
BackendKind::Gemini => {
"https://generativelanguage.googleapis.com/v1beta/openai".to_string()
diff --git a/crates/proxy/src/config/mod.rs b/crates/proxy/src/config/mod.rs
index 4727fd0..0287e75 100644
--- a/crates/proxy/src/config/mod.rs
+++ b/crates/proxy/src/config/mod.rs
@@ -47,6 +47,35 @@ pub enum BackendAuth {
AzureApiKey(String),
}
+/// Strip curly/smart quotes and other non-ASCII punctuation that copy-paste
+/// from rich-text sources (Slack, docs, web pages) can silently inject into
+/// API keys. Logs a warning so the operator notices.
+pub fn sanitize_api_key(key: &str) -> String {
+ // U+2018 ' U+2019 ' U+201C " U+201D "
+ let cleaned: String = key
+ .chars()
+ .filter(|c| !matches!(c, '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}'))
+ .collect();
+ if cleaned.len() != key.len() {
+ tracing::warn!(
+ "stripped curly/smart quotes from API key \
+ (likely copy-pasted from a rich-text source)"
+ );
+ }
+ cleaned
+}
+
+/// Strip a trailing `/v1` or `/v1/` suffix from a base URL.
+///
+/// The OpenAI client always appends `/v1/chat/completions`, so provider URLs
+/// that include `/v1` (e.g. `https://openrouter.ai/api/v1`) would produce a
+/// doubled path without this.
+pub fn strip_v1_suffix(url: &str) -> &str {
+ url.strip_suffix("/v1/")
+ .or_else(|| url.strip_suffix("/v1"))
+ .unwrap_or(url)
+}
+
impl fmt::Debug for BackendAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -154,16 +183,19 @@ impl Config {
.unwrap_or("https://api.openai.com");
let base_url = std::env::var("OPENAI_BASE_URL")
.unwrap_or_else(|_| provider_default_url.to_string());
+ let base_url = strip_v1_suffix(&base_url).to_string();
if let Err(e) = validate_base_url(&base_url) {
panic!("OPENAI_BASE_URL rejected: {e}");
}
// For stub providers, fall back to their env var (e.g. GROQ_API_KEY) when
// OPENAI_API_KEY is not set.
- let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| {
- stub_provider
- .and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
- .unwrap_or_default()
- });
+ let api_key = sanitize_api_key(
+ &std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| {
+ stub_provider
+ .and_then(|p| p.env_vars.iter().find_map(|v| std::env::var(v).ok()))
+ .unwrap_or_default()
+ }),
+ );
let backend_auth = BackendAuth::BearerToken(api_key.clone());
let openai_api_format = match std::env::var("OPENAI_API_FORMAT")
.unwrap_or_else(|_| "chat".into())
@@ -196,9 +228,11 @@ impl Config {
let deployment = std::env::var("AZURE_OPENAI_DEPLOYMENT").unwrap_or_else(|_| {
panic!("AZURE_OPENAI_DEPLOYMENT is required when BACKEND=azure")
});
- let api_key = std::env::var("AZURE_OPENAI_API_KEY").unwrap_or_else(|_| {
- panic!("AZURE_OPENAI_API_KEY is required when BACKEND=azure")
- });
+ let api_key = sanitize_api_key(
+ &std::env::var("AZURE_OPENAI_API_KEY").unwrap_or_else(|_| {
+ panic!("AZURE_OPENAI_API_KEY is required when BACKEND=azure")
+ }),
+ );
let api_version = std::env::var("AZURE_OPENAI_API_VERSION")
.unwrap_or_else(|_| "2024-10-21".to_string());
@@ -236,9 +270,9 @@ impl Config {
validate_gcp_identifier("VERTEX_REGION", ®ion);
let backend_auth = if let Ok(api_key) = std::env::var("VERTEX_API_KEY") {
- BackendAuth::GoogleApiKey(api_key)
+ BackendAuth::GoogleApiKey(sanitize_api_key(&api_key))
} else if let Ok(token) = std::env::var("GOOGLE_ACCESS_TOKEN") {
- BackendAuth::BearerToken(token)
+ BackendAuth::BearerToken(sanitize_api_key(&token))
} else {
panic!("VERTEX_API_KEY or GOOGLE_ACCESS_TOKEN is required when BACKEND=vertex");
};
@@ -267,8 +301,10 @@ impl Config {
}
}
BackendKind::Gemini => {
- let api_key = std::env::var("GEMINI_API_KEY")
- .unwrap_or_else(|_| panic!("GEMINI_API_KEY is required when BACKEND=gemini"));
+ let api_key = sanitize_api_key(
+ &std::env::var("GEMINI_API_KEY")
+ .unwrap_or_else(|_| panic!("GEMINI_API_KEY is required when BACKEND=gemini")),
+ );
let base_url = std::env::var("GEMINI_BASE_URL").unwrap_or_else(|_| {
"https://generativelanguage.googleapis.com/v1beta".to_string()
@@ -296,9 +332,11 @@ impl Config {
}
}
BackendKind::Anthropic => {
- let api_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| {
- panic!("ANTHROPIC_API_KEY is required when BACKEND=anthropic")
- });
+ let api_key = sanitize_api_key(
+ &std::env::var("ANTHROPIC_API_KEY").unwrap_or_else(|_| {
+ panic!("ANTHROPIC_API_KEY is required when BACKEND=anthropic")
+ }),
+ );
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());
@@ -780,11 +818,12 @@ impl MultiConfig {
other => panic!("unknown backend kind '{other}' for backend '{name}'"),
};
- let api_key = tb
- .api_key
- .as_deref()
- .map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
- .unwrap_or_default();
+ let api_key = sanitize_api_key(
+ &tb.api_key
+ .as_deref()
+ .map(|v| resolve_env_value(v).unwrap_or_else(|e| panic!("backend '{name}': {e}")))
+ .unwrap_or_default(),
+ );
let (base_url, backend_auth, model_mapping, api_format) = match &kind {
BackendKind::OpenAI => {
@@ -871,8 +910,10 @@ impl MultiConfig {
let auth = if !api_key.is_empty() {
BackendAuth::GoogleApiKey(api_key.clone())
} else if let Some(token_ref) = &tb.access_token {
- let token = resolve_env_value(token_ref)
- .unwrap_or_else(|e| panic!("backend '{name}': {e}"));
+ let token = sanitize_api_key(
+ &resolve_env_value(token_ref)
+ .unwrap_or_else(|e| panic!("backend '{name}': {e}")),
+ );
BackendAuth::BearerToken(token)
} else {
panic!("backend '{name}': api_key or access_token is required for vertex");
diff --git a/crates/proxy/src/config/simple.rs b/crates/proxy/src/config/simple.rs
index 879ae54..de7b753 100644
--- a/crates/proxy/src/config/simple.rs
+++ b/crates/proxy/src/config/simple.rs
@@ -225,10 +225,12 @@ pub fn parse_simple_yaml(yaml: &str) -> SimpleParsed {
for entry in &config.models {
let norm = normalize_entry(entry);
let kind = parse_kind(&norm.provider);
- let api_key = norm
- .api_key
- .clone()
- .unwrap_or_else(|| default_api_key_for_provider(&norm.provider, &kind));
+ let api_key = super::sanitize_api_key(
+ &norm
+ .api_key
+ .clone()
+ .unwrap_or_else(|| default_api_key_for_provider(&norm.provider, &kind)),
+ );
let base_url = if kind == BackendKind::AzureOpenAI {
// Azure always builds a full deployment URL (api_base or env var + deployment + version).
default_base_url(&kind, &norm)
@@ -517,8 +519,11 @@ fn default_api_key_for_provider(provider: &str, kind: &BackendKind) -> String {
fn default_base_url(kind: &BackendKind, entry: &NormalizedEntry) -> String {
match kind {
- BackendKind::OpenAI => std::env::var("OPENAI_BASE_URL")
- .unwrap_or_else(|_| "https://api.openai.com".to_string()),
+ BackendKind::OpenAI => {
+ let url = std::env::var("OPENAI_BASE_URL")
+ .unwrap_or_else(|_| "https://api.openai.com".to_string());
+ super::strip_v1_suffix(&url).to_string()
+ }
BackendKind::Gemini => {
let base = std::env::var("GEMINI_BASE_URL")
.unwrap_or_else(|_| "https://generativelanguage.googleapis.com/v1beta".to_string());
diff --git a/docs/proxy-architecture.md b/docs/proxy-architecture.md
index a470a30..4f970be 100644
--- a/docs/proxy-architecture.md
+++ b/docs/proxy-architecture.md
@@ -1,5 +1,32 @@
# Proxy Architecture
+## Crate Structure
+
+Cargo workspace with five crates:
+
+### `crates/providers` (lib: `anyllm_providers`)
+Metadata-only catalog: no HTTP, no IO. `ProviderDef` (protocol, auth, env vars, LiteLLM prefix) and `ModelDef` (context window, capabilities). Registry functions in `registry.rs`. Add a new provider: create `providers/src/providers/.rs`, register in `providers/mod.rs` and `registry.rs`. OpenAI-compatible providers route through the existing `OpenAIClient` automatically.
+
+### `crates/client` (lib: `anyllm_client`)
+Async HTTP client (Anthropic-in, Anthropic-out). `ClientBuilder`, `ToolBuilder`, `messages_stream()` returning `impl Stream`.
+
+### `crates/translator` (lib: `anyllm_translate`)
+Pure translation logic, no IO. Stateless `fn(A) -> B` mapping between Anthropic and OpenAI types.
+- `anthropic/`: Anthropic Messages API types
+- `openai/`: OpenAI types (Chat Completions + Responses API)
+- `mapping/`: Conversion functions (message_map, tools_map, streaming_map, reverse_streaming_map, responses_*, warnings)
+- `middleware/`: Request/response handler orchestrating translation
+
+### `crates/batch_engine` (lib: `anyllm_batch_engine`)
+HTTP-agnostic batch orchestration: job queue, file storage, webhook delivery.
+
+### `crates/proxy` (bin: `anyllm_proxy`)
+HTTP proxy on axum + reqwest:
+- `server/`: Routes, middleware (auth, rate limit, request ID, size/concurrency limits), SSE streaming, passthrough handlers. `bedrock_native.rs`: Bedrock Converse/InvokeModel native passthrough (SigV4 handled by proxy). `generic_passthrough.rs`: catch-all `/v1/{*path}` for Translate mode (registered last).
+- `backend/`: `BackendClient` enum dispatching to OpenAI/Azure/Vertex/Gemini/Anthropic/Bedrock with retry
+- `admin/`: Admin server (localhost:3001), virtual key CRUD, managed backend CRUD (`routes/managed_backends.rs`), model management, audit log, WebSocket live updates
+- `admin-ui/`: React 19 + TypeScript SPA (Vite). Build: `cd crates/proxy/admin-ui && npm run build`
+
## Data Flow
```