0?a=e.__k[s]=ve(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[s]=a,f=s+c,a.__=e,a.__b=e.__b+1,d=null,(l=a.__i=qn(a,o,f,h))!=-1&&(h--,(d=o[l])&&(d.__u|=2)),d==null||d.__v==null?(l==-1&&(i>p?c--:if?c--:c++,a.__u|=4))):e.__k[s]=null;if(h)for(s=0;s
(p?1:0)){for(i=o-1,s=o+1;i>=0||s=0?i--:s++])!=null&&(2&l.__u)==0&&d==l.key&&f==l.type)return a}return-1}function kt(e,t,o){t[0]=="-"?e.setProperty(t,o??""):e[t]=o==null?"":typeof o!="number"||Ln.test(t)?o:o+"px"}function be(e,t,o,r,i){var s,a;e:if(t=="style")if(typeof o=="string")e.style.cssText=o;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)o&&t in o||kt(e.style,t,"");if(o)for(t in o)r&&o[t]==r[t]||kt(e.style,t,o[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(Tt,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=o,o?r?o[ie]=r[ie]:(o[ie]=Ke,e.addEventListener(t,s?Je:Be,s)):e.removeEventListener(t,s?Je:Be,s);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=o??"";break e}catch{}typeof o=="function"||(o==null||o===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&o==1?"":o))}}function wt(e){return function(t){if(this.l){var o=this.l[t.type+e];if(t[xe]==null)t[xe]=Ke++;else if(t[xe]0?e:Se(e)?e.map(Pt):e.constructor!==void 0?null:D({},e)}function Un(e,t,o,r,i,s,a,d,f){var l,p,h,c,m,x,$,k=o.props||ke,T=t.props,v=t.type;if(v=="svg"?i="http://www.w3.org/2000/svg":v=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),s!=null){for(l=0;l=o.__.length&&o.__.push({}),o.__[e]}function Z(e){return Oe=1,nt(Vt,e)}function nt(e,t,o){var r=tt(ae++,2);if(r.t=e,!r.__c&&(r.__=[o?o(t):Vt(void 0,t),function(d){var f=r.__N?r.__N[0]:r.__[0],l=r.t(f,d);f!==l&&(r.__N=[l,r.__[1]],r.__c.setState({}))}],r.__c=C,!C.__f)){var i=function(d,f,l){if(!r.__c.__H)return!0;var p=!1,h=r.__c.props!==d;if(r.__c.__H.__.some(function(m){if(m.__N){p=!0;var x=m.__[0];m.__=m.__N,m.__N=void 0,x!==m.__[0]&&(h=!0)}}),s){var c=s.call(this,d,f,l);return p?c||h:c}return!p||h};C.__f=!0;var s=C.shouldComponentUpdate,a=C.componentWillUpdate;C.componentWillUpdate=function(d,f,l){if(this.__e){var p=s;s=void 0,i(d,f,l),s=p}a&&a.call(this,d,f,l)},C.shouldComponentUpdate=i}return r.__N||r.__}function L(e,t){var o=tt(ae++,3);!S.__s&&Gt(o.__H,t)&&(o.__=e,o.u=t,C.__H.__h.push(o))}function Ie(e){return Oe=5,Gn(function(){return{current:e}},[])}function Gn(e,t){var o=tt(ae++,7);return Gt(o.__H,t)&&(o.__=e(),o.__H=t,o.__h=e),o.__}function Vn(){for(var e;e=Wt.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Te),t.__h.some(et),t.__h=[]}catch(o){t.__h=[],S.__e(o,e.__v)}}}S.__b=function(e){C=null,Dt&&Dt(e)},S.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),qt&&qt(e,t)},S.__r=function(e){Lt&&Lt(e),ae=0;var t=(C=e.__c).__H;t&&(Xe===C?(t.__h=[],C.__h=[],t.__.some(function(o){o.__N&&(o.__=o.__N),o.u=o.__N=void 0})):(t.__h.some(Te),t.__h.some(et),t.__h=[],ae=0)),Xe=C},S.diffed=function(e){zt&&zt(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Wt.push(t)!==1&&Nt===S.requestAnimationFrame||((Nt=S.requestAnimationFrame)||Bn)(Vn)),t.__H.__.some(function(o){o.u&&(o.__H=o.u,o.u=void 0)})),Xe=C=null},S.__c=function(e,t){t.some(function(o){try{o.__h.some(Te),o.__h=o.__h.filter(function(r){return!r.__||et(r)})}catch(r){t.some(function(i){i.__h&&(i.__h=[])}),t=[],S.__e(r,o.__v)}}),Ht&&Ht(e,t)},S.unmount=function(e){Ft&&Ft(e);var t,o=e.__c;o&&o.__H&&(o.__H.__.some(function(r){try{Te(r)}catch(i){t=i}}),o.__H=void 0,t&&S.__e(t,o.__v))};var Ut=typeof requestAnimationFrame=="function";function Bn(e){var t,o=function(){clearTimeout(r),Ut&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(o,35);Ut&&(t=requestAnimationFrame(o))}function Te(e){var t=C,o=e.__c;typeof o=="function"&&(e.__c=void 0,o()),C=t}function et(e){var t=C;e.__c=e.__(),C=t}function Gt(e,t){return!e||e.length!==t.length||t.some(function(o,r){return o!==e[r]})}function Vt(e,t){return typeof t=="function"?t(e):t}var Ee=[{label:null,items:["get-running"]},{label:"Before production",items:["auth","cache_store","observability"]},{label:"Runs and streaming",items:["runs","websocket","ag_ui","remote_tools","media"]},{label:"Project wiring",items:["checkpointer","id_generator","thread_name_generator","store","injectq"]},{label:"Developer tools",items:["test","evaluation"]},{label:"Reference",items:["environment"]}],z={"get-running":{title:"Get running",icon:"flag",description:"Only the agent graph is required. Everything else is optional."},environment:{title:"Server environment (.env)",icon:"terminal",description:"Variables the server reads from .env. The editor never reads that file, so values are not shown."}},ot={"auth.path":"auth-path"},Bt="tg-config-token",Jt=!1;function Jn(){let e=new URLSearchParams(location.hash.slice(1)).get("token")||"";try{e&&sessionStorage.setItem(Bt,e);let t=sessionStorage.getItem(Bt)||"";return Jt=t!==""&&t===(e||t),e||t}catch{return e}}var le=Jn(),rt=e=>Jt||!le?`#${e}`:`#token=${encodeURIComponent(le)}&page=${e}`;function Kt(){let e=location.hash.slice(1);if(e.includes("=")){let t=new URLSearchParams(e);return t.get("page")||t.get("section")||""}try{return decodeURIComponent(e)}catch{return e}}var u={schema:null,sections:[],nav:[],fields:new Map,config:{},version:null,exists:!1,path:"",parseError:null,saved:"",validated:null,issues:[],page:"get-running",query:"",sideOpen:!0,sideRender:!0,sideShown:!0,drawerOpen:!1,tab:"json",copied:!1,busy:null,toolParams:{},toasts:[],fatal:null},Yt=()=>{},Qt=e=>{Yt=e};function g(e){e&&e(u),Yt()}var Zt=e=>JSON.parse(JSON.stringify(e)),M=()=>JSON.stringify(u.config),y=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),Ae=(e,t)=>t.reduce((o,r)=>y(o)?o[r]:void 0,e),ce=(e,t)=>`${e} ${t}${e===1?"":"s"}`;function je(e,t,o){let r=e;for(let i of t.slice(0,-1))y(r[i])||(r[i]={}),r=r[i];r[t[t.length-1]]=o}var Kn=new Set(["cache_store.rate_limit","cache_store.token_revocation"]);function st(e,t){let o=[],r=e;for(let i of t.slice(0,-1)){if(!y(r))return;o.push([r,i]),r=r[i]}y(r)&&delete r[t[t.length-1]];for(let i=o.length-1;i>=1;i--){let[s,a]=o[i];if(Kn.has(t.slice(0,i+1).join(".")))break;if(y(s[a])&&Object.keys(s[a]).length===0)delete s[a];else break}}function de(e){let t=Ae(u.config,e);return t!==void 0?t:u.fields.get(e.join("."))?.default}var Yn=(e,t)=>JSON.stringify(e)===JSON.stringify(t),E=e=>u.sections.find(t=>t.id===e),H=()=>M()!==u.saved,Pe=e=>!e.optional||e.key in u.config&&u.config[e.key]!==null,X=e=>u.issues.filter(t=>t.section===e),O=e=>u.issues.find(t=>t.path===e);function ue(e=u.issues){let t=e.filter(o=>o.level==="error").length;return{errors:t,warnings:e.length-t}}var Me=e=>ot[e]||`f-${e.replaceAll(".","-")}`,it=e=>{let t=Ae(u.config,e.path);return t!==void 0&&!Yn(t,e.default)},Xt=e=>e.id==="core"?!1:e.id==="auth"?u.config.auth!=null:e.optional?Pe(e):e.fields.some(it);function en(e){let t=X(e);return t.find(o=>o.level==="error")||t[0]||null}var tn=e=>!!e.show_if&&de(e.show_if.path)!==e.show_if.equals;function nn(e){let o=u.fields.get(e.show_if.path.join("."))?.label||e.show_if.path.join("."),r=e.show_if.equals;return`Used when ${o} is ${r===!0?"on":r===!1?"off":String(r)}.`}var pe=e=>{let t=E(e);return z[e]?{id:e,...z[e]}:t?{id:e,title:t.title,subtitle:t.subtitle,description:t.description,icon:null}:null},on=()=>/^[\w.]+:[\w.]+$/.test(String(u.config.agent||""));async function lt(e,t,o){let r={"X-10xGraph-Token":le};o!==void 0&&(r["Content-Type"]="application/json");let i=await fetch(t,{method:e,headers:r,body:o===void 0?void 0:JSON.stringify(o)}),s={};try{s=await i.json()}catch{}return{ok:i.ok,status:i.status,data:s}}var at=(e,t)=>g(o=>{o.fatal={title:e,text:t}});async function an(){if(!le)return at("Open the link printed by 10xgraph config","This page needs the session token included in that link.");let e;try{e=await lt("GET","/api/state")}catch{return at("Cannot reach the config server","Check that 10xgraph config is still running in your terminal, then reload.")}if(!e.ok)return at("This link has expired",e.data.error||"Restart 10xgraph config and open the new link it prints.");let t=e.data;g(o=>{o.schema=t.schema,o.sections=t.schema.sections;let r=new Set(Ee.flatMap(a=>a.items)),i=o.sections.map(a=>a.id).filter(a=>a!=="core"&&!r.has(a));o.nav=Ee.map((a,d)=>({label:a.label,items:dz[f]||E(f)):[...i,...a.items]}));for(let a of o.sections)for(let d of a.fields)o.fields.set(d.path.join("."),d);o.config=t.config,o.version=t.version,o.exists=t.exists,o.path=t.path,o.parseError=t.parse_error,o.issues=t.issues;let s=t.exists&&!t.parse_error;o.saved=s?M():"",o.validated=s?M():null}),Ne({focus:!1}),history.replaceState(null,"",rt(u.page))}var rn=null;async function ln(){let e=M();if(!(!u.schema||u.validated===e||rn===e)&&!Object.values(u.toolParams).some(t=>t.error)){rn=e;try{let t=await lt("POST","/api/validate",{config:JSON.parse(e)});if(!t.ok)return;g(o=>{o.issues=t.data.issues,o.validated=e})}catch{}}}async function Re(){if(!(u.busy||!u.schema)){if(u.exists&&!H()&&!u.parseError)return F("success","No changes to save.");if(Qn()){g(e=>{e.busy="save"});try{let e=await lt("POST","/api/save",{config:u.config,version:u.version});if(e.data.issues&&(u.issues=e.data.issues,u.validated=M()),e.ok){u.version=e.data.version,u.config=e.data.config,u.toolParams={},u.validated=M(),u.saved=M(),u.exists=!0,u.parseError=null;let t=e.data.path.split(/[\\/]/).pop();F("success",e.data.backup?`Saved ${t}. The previous version is in ${t}.bak.`:`Created ${t}.`)}else if(e.status===409)F("error",e.data.error,{label:"Reload",run:()=>location.reload()});else if(e.status===422){let{errors:t}=ue();F("error",`Not saved: ${ce(t,"error")} to fix first.`,{label:"Show",run:Zn})}else F("error",e.data.error||"Save failed.")}catch{F("error","Cannot reach the config server.")}finally{g(e=>{e.busy=null})}}}}function Qn(){let e=Object.values(u.toolParams).some(t=>t.error);return e&&(R("remote_tools"),F("error","A remote tool has invalid parameters JSON.")),!e}function cn(e,t){g(o=>{t?o.config[e.key]=Zt(e.default):delete o.config[e.key],e.key==="remote_tools"&&(o.toolParams={})})}function Zn(){let e=u.issues.find(t=>t.level==="error")||u.issues[0];e&&R(e.section,Me(e.path))}var Xn=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth";function eo(e){return e==="core"?"get-running":z[e]||E(e)?e:"get-running"}function Ne({focus:e=!0}={}){g(t=>{t.page=eo(Kt()),t.query="",t.drawerOpen=!1}),e&&requestAnimationFrame(()=>{window.scrollTo(0,0),document.getElementById("page-title")?.focus({preventScroll:!0})})}function R(e,t){let o=rt(e);location.hash!==o&&history.pushState(null,"",o),Ne(),t&&requestAnimationFrame(()=>requestAnimationFrame(()=>{let r=document.getElementById(t);r&&(r.scrollIntoView({block:"center",behavior:Xn()}),r.focus({preventScroll:!0}))}))}function De(e){g(t=>{t.query=e})}var to=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches,sn;function ee(e,{instant:t=!1}={}){try{localStorage.setItem("tg-sidebar",e?"shown":"hidden")}catch{}clearTimeout(sn);let o=t||to();g(r=>{r.sideOpen=e,e?(r.sideRender=!0,r.sideShown=o):r.sideShown=!1,!e&&o&&(r.sideRender=!1)}),e&&!o&&requestAnimationFrame(()=>requestAnimationFrame(()=>g(r=>{r.sideShown=!0}))),!e&&!o&&(sn=setTimeout(()=>g(r=>{r.sideOpen||(r.sideRender=!1)}),200))}function ct(){u.sideOpen||ee(!0,{instant:!0}),requestAnimationFrame(()=>{let e=document.getElementById("q");e?.focus(),e?.select()})}function dn(e){g(t=>{e&&(t.tab=e),t.drawerOpen=!0})}var Le=()=>g(e=>{e.drawerOpen=!1});async function un(){let e="10xgraph dev";try{await navigator.clipboard.writeText(e)}catch{try{let t=document.createElement("textarea");t.value=e,document.body.append(t),t.select(),document.execCommand("copy"),t.remove()}catch{}}g(t=>{t.copied=!0}),setTimeout(()=>g(t=>{t.copied=!1}),2e3)}async function pn(){try{await navigator.clipboard.writeText(JSON.stringify(u.config,null,2)+`
-`),F("success","Copied 10xgraph.json to the clipboard.")}catch{F("error","The clipboard is not available here.")}}var no=0;function F(e,t,o){let r=++no;g(i=>{i.toasts=[...i.toasts,{id:r,kind:e,message:t,action:o}]}),setTimeout(()=>ze(r),o?8e3:4e3)}function ze(e){g(t=>{t.toasts=t.toasts.filter(o=>o.id!==e)})}var fn={box:'',key:'',shield:'',gauge:'',arrows:'',activity:'',layers:'',wrench:'',flask:'',target:'',checkCircle:'',xCircle:'',alert:'',info:'',file:'',copy:'',x:'',plus:'',trash:'',panel:'',chevronDown:'',expand:'',collapse:'',power:'',spinner:'',book:'',flag:'',lock:'',play:'',bolt:'',terminal:'',search:'',chevronRight:'',code:'',circle:'',external:''},K={"get-running":"flag",environment:"terminal",core:"flag",auth:"lock",cache_store:"gauge",observability:"activity",runs:"play",websocket:"bolt",ag_ui:"external",remote_tools:"wrench",media:"file",checkpointer:"layers",id_generator:"key",thread_name_generator:"file",store:"box",injectq:"arrows",test:"flask",evaluation:"target"};var oo=0;function n(e,t,o,r,i,s){t||(t={});var a,d,f=t;if("ref"in f)for(d in f={},t)d=="ref"?a=t[d]:f[d]=t[d];var l={type:e,props:f,key:o,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--oo,__i:-1,__u:0,__source:i,__self:s};if(typeof e=="function"&&(a=e.defaultProps))for(d in a)f[d]===void 0&&(f[d]=a[d]);return b.vnode&&b.vnode(l),l}function _({name:e,class:t="size-4"}){return/(^|\s)size-/.test(t)||(t=`size-4 ${t}`),n("span",{class:`inline-flex shrink-0 ${t}`,"aria-hidden":"true",children:n("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"size-full",dangerouslySetInnerHTML:{__html:fn[e]}})})}function te({class:e,children:t}){return n("span",{class:`inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-semibold ${e}`,children:t})}function dt({id:e,on:t,label:o,onChange:r}){return n("button",{id:e,type:"button",role:"switch","aria-checked":t?"true":"false","aria-label":o,class:`focus-ring relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 before:absolute before:-inset-2.5 before:content-[''] ${t?"bg-brand-solid":"bg-line-strong"}`,onClick:()=>r(!t),children:n("span",{class:`pointer-events-none inline-block size-5 rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-200 ease-out ${t?"translate-x-[18px]":"translate-x-0.5"}`})})}function Y({id:e,label:t,options:o,value:r,onPick:i}){return n("div",{id:e,role:"radiogroup","aria-label":t,class:"inline-flex max-w-full flex-wrap gap-0.5 rounded-[10px] border border-line-strong bg-raised p-0.5",children:o.map(([s,a])=>n("button",{type:"button",role:"radio","aria-checked":s===r?"true":"false",class:`focus-ring min-h-10 cursor-pointer rounded-lg px-3.5 text-sm font-medium transition-colors duration-150 ${s===r?"bg-surface text-ink shadow-card":"text-muted hover:text-ink"}`,onClick:()=>{s!==r&&i(s)},children:a},s))})}function ne({id:e,options:t,value:o,invalid:r,onChange:i}){return n("div",{class:"relative",children:[n("select",{id:e,value:o,class:`input cursor-pointer appearance-none pr-9 font-mono ${r?"input-invalid":""}`,onChange:s=>i(s.currentTarget.value),children:t.map(([s,a])=>n("option",{value:s,children:a},s))}),n(_,{name:"chevronDown",class:"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-faint"})]})}function W({id:e,values:t,placeholder:o,suggestions:r,onChange:i}){let s=Ie(null),a=f=>{let l=f.trim();s.current&&(s.current.value=""),l&&!t.includes(l)&&i([...t,l])},d=r?`${e}-options`:void 0;return n("div",{class:"flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-line-strong bg-sunken px-1.5 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-ring sm:py-1",onClick:f=>{f.target===f.currentTarget&&s.current?.focus()},children:[t.map((f,l)=>n("span",{class:"inline-flex h-7 items-center gap-0.5 rounded-md border border-line bg-raised pr-0.5 pl-2 font-mono text-xs text-body sm:h-6",children:[f,n("button",{type:"button","aria-label":`Remove ${f}`,class:"inline-flex size-6 cursor-pointer items-center justify-center rounded text-faint transition-colors hover:bg-hover hover:text-ink sm:size-5",onClick:()=>i(t.filter((p,h)=>h!==l)),children:n(_,{name:"x",class:"size-3"})})]},f)),n("input",{ref:s,id:e,type:"text",list:d,placeholder:t.length?"Add...":o,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:"h-7 min-w-[7rem] flex-1 bg-transparent px-1 font-mono text-base text-ink outline-none placeholder:text-faint sm:text-sm",onKeyDown:f=>{let l=f.currentTarget;(f.key==="Enter"||f.key===",")&&l.value.trim()?(f.preventDefault(),a(l.value)):f.key==="Backspace"&&!l.value&&t.length&&i(t.slice(0,-1))},onChange:f=>{r?.includes(f.currentTarget.value.trim())&&a(f.currentTarget.value)},onBlur:f=>a(f.currentTarget.value)}),r?n("datalist",{id:d,children:r.filter(f=>!t.includes(f)).map(f=>n("option",{value:f},f))}):null]})}function fe({label:e,path:t,help:o,id:r,required:i}){return n("div",{class:"min-w-0",children:[n("label",{for:r,class:"block text-sm font-medium text-ink",children:[e,i?n("span",{class:"text-danger",children:" *"}):null]}),n("div",{class:"mt-0.5 font-mono text-xs text-faint",children:t}),o?n("p",{class:"mt-1.5 text-[13px] leading-5 text-muted",children:o}):null]})}function G({issue:e}){if(!e)return null;let t=e.level==="error";return n("p",{class:`mt-2 flex gap-1.5 text-[13px] leading-5 ${t?"text-danger":"text-warn"}`,role:t?"alert":void 0,children:[n(_,{name:t?"xCircle":"alert",class:"mt-0.5 size-3.5"}),n("span",{children:e.message})]})}function ut({children:e}){return n("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-8",children:e})}function V({id:e,label:t,path:o,value:r,placeholder:i,onInput:s,issue:a,help:d}){return n(ut,{children:[n(fe,{label:t,path:o,help:d,id:e}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n("input",{id:e,type:"text",value:r??"",placeholder:i,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${a?.level==="error"?"input-invalid":""}`,onInput:f=>s(f.currentTarget.value)}),n(G,{issue:a})]})]})}function pt({name:e,label:t,options:o,value:r,onPick:i}){return n("div",{role:"radiogroup","aria-label":t,class:"grid gap-3 sm:grid-cols-2",children:o.map(([s,a,d])=>{let f=s===r;return n("button",{type:"button",role:"radio","aria-checked":f?"true":"false",id:`${e}-${s}`,class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 ${f?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{f||i(s)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${f?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:f?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:a}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:d})]})]},s)})})}function A({title:e,children:t}){return n("section",{children:[e?n("h4",{class:"eyebrow mb-2.5",children:e}):null,t]})}var ro={info:["info","border-brand/20 bg-brand-soft","text-brand"],warn:["alert","border-warn/25 bg-warn-soft","text-warn"],error:["xCircle","border-danger/25 bg-danger-soft","text-danger"]};function me({kind:e,title:t,lines:o}){let[r,i,s]=ro[e];return n("div",{class:`flex gap-3 rounded-xl border px-4 py-3 ${i}`,role:e==="error"?"alert":void 0,children:[n(_,{name:r,class:`mt-0.5 size-4 ${s}`}),n("div",{class:"min-w-0 space-y-1 text-[13px] leading-5 text-body",children:[t?n("p",{class:"font-medium text-ink",children:t}):null,o.map((a,d)=>n("p",{children:a},d))]})]})}function ft({issue:e}){return n(me,{kind:e.level==="error"?"error":"warn",lines:[n("span",{children:[e.path?n("code",{class:"mr-1.5 font-mono text-xs text-muted",children:e.path}):null,e.message]})]})}function mn({icon:e,tint:t,title:o,text:r,children:i}){return n("div",{class:"px-6 py-14 text-center",children:[n("div",{class:`mx-auto flex size-11 items-center justify-center rounded-full bg-raised ${t}`,children:n(_,{name:e,class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:o}),n("p",{class:"mx-auto mt-1 max-w-xs text-sm leading-6 text-muted",children:r}),i]})}function hn(){let e=Ie(null);L(()=>{let a=e.current;a&&(u.drawerOpen&&!a.open?a.showModal():!u.drawerOpen&&a.open&&a.close())});let t=u.issues.length>0,o=t&&u.tab==="problems"?"problems":"json",{errors:r,warnings:i}=ue(),s=(a,d,f)=>n("button",{type:"button",role:"tab",id:`tab-${a}`,"aria-selected":o===a?"true":"false",class:`focus-ring inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-150 ${o===a?"bg-raised text-ink":"text-muted hover:text-ink"}`,onClick:()=>g(l=>{l.tab=a}),children:[d,f]});return n("dialog",{ref:e,"aria-label":"10xgraph.json preview",class:"m-0 ml-auto h-dvh max-h-none w-[min(540px,100vw)] border-0 border-l border-line-strong bg-surface p-0 text-body shadow-pop backdrop:bg-black/45",onClose:()=>{u.drawerOpen&&Le()},onClick:a=>{a.target===a.currentTarget&&Le()},children:n("div",{class:"flex h-full flex-col",children:[n("div",{class:"flex h-14 shrink-0 items-center gap-1 border-b border-line px-3",children:[n("div",{role:"tablist",class:"flex flex-1 gap-1",children:[s("json","10xgraph.json",null),t?s("problems","Problems",n("span",{class:`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${r?"bg-danger-soft text-danger":"bg-warn-soft text-warn"}`,children:r+i})):null]}),n("button",{type:"button",class:"icon-btn","aria-label":"Close preview",onClick:Le,children:n(_,{name:"x",class:"size-5"})})]}),n("div",{class:"min-h-0 flex-1 overflow-y-auto overscroll-contain",children:o==="problems"?n(so,{}):n(ao,{})})]})})}function so(){let{errors:e,warnings:t}=ue(),o=u.sections.map(r=>[r,X(r.id)]).filter(([,r])=>r.length);return n("div",{children:[n("div",{class:"border-b border-line px-4 py-3",children:n("p",{class:"text-sm text-muted",children:[n("span",{class:e?"font-medium text-danger":"",children:ce(e,"error")})," \xB7 ",n("span",{class:t?"font-medium text-warn":"",children:ce(t,"warning")})]})}),n("div",{class:"space-y-5 px-3 py-4",children:o.map(([r,i])=>n("div",{children:[n("p",{class:"eyebrow mb-1.5 flex items-center gap-2 px-2",children:[n(_,{name:K[r.id]||"box",class:"size-3.5"}),r.title]}),n("ul",{class:"space-y-0.5",children:i.map((s,a)=>{let d=s.level==="error";return n("li",{children:n("button",{type:"button",onClick:()=>R(r.id,Me(s.path)),class:"focus-ring flex min-h-11 w-full cursor-pointer gap-2.5 rounded-lg px-2 py-2 text-left transition-colors duration-150 hover:bg-raised",children:[n(_,{name:d?"xCircle":"alert",class:`mt-0.5 size-4 ${d?"text-danger":"text-warn"}`}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-[13px] leading-5 text-body",children:s.message}),s.path?n("span",{class:"mt-0.5 block truncate font-mono text-xs text-faint",children:s.path}):null]})]})},a)})})]},r.id))})]})}function io(e){return e.replace(/&/g,"&").replace(//g,">").replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,o=>{let r="var(--af-json-number)";return o.startsWith('"')?r=o.endsWith(":")?"var(--af-json-key)":"var(--af-json-string)":(o==="true"||o==="false"||o==="null")&&(r="var(--af-json-literal)"),`${o}`})}function ao(){let e=JSON.stringify(u.config,null,2).split(`
-`);return n("div",{children:[n("div",{class:"sticky top-0 flex items-center justify-between gap-3 border-b border-line bg-surface px-4 py-2.5",children:[n("p",{class:"text-xs text-faint",children:H()||!u.exists?"Preview of what Save will write":"Matches the file on disk"}),n("button",{type:"button",class:"btn btn-ghost h-8 px-2.5 text-xs",onClick:pn,children:[n(_,{name:"copy",class:"size-3.5"}),"Copy"]})]}),n("div",{class:"py-3 pr-4 font-mono text-[12.5px] leading-5",children:e.map((t,o)=>n("div",{class:"flex",children:[n("span",{class:"w-10 shrink-0 pr-3 text-right text-faint/70 select-none",children:o+1}),n("span",{class:"min-w-0 flex-1 break-all whitespace-pre-wrap",dangerouslySetInnerHTML:{__html:io(t)}})]},o))})]})}function ht(e){let t=[];for(let o of e){let r=o.group||"",i=t.find(s=>s.name===r);i||t.push(i={name:r,fields:[]}),i.fields.push(o)}return t}var q=(e,t)=>g(o=>{if(t===void 0){st(o.config,e);return}let r=e.length>1?o.sections.find(i=>i.optional&&i.key===e[0]):null;r&&(o.config[r.key]===void 0||o.config[r.key]===null)&&(o.config[r.key]=JSON.parse(JSON.stringify(r.default))),je(o.config,e,t)}),lo=e=>`f-${e.path.join("-")}`;function co({id:e,value:t,field:o,invalid:r,disabled:i}){let[s,a]=Z(String(t??""));return L(()=>{(s===""?t!==void 0:Number(s)!==t)&&a(String(t??""))},[t]),n("input",{id:e,type:"number",inputmode:"decimal",value:s,disabled:i,min:o.min,step:o.step??"any",placeholder:o.placeholder??o.default??"",class:`input max-w-40 font-mono tabular-nums ${r?"input-invalid":""}`,onInput:d=>{let f=d.currentTarget.value;a(f),f===""?q(o.path,void 0):Number.isNaN(Number(f))||q(o.path,Number(f))}})}function mt({field:e,value:t}){return e.exclusive_min===void 0||typeof t!="number"||t>e.exclusive_min?null:n("p",{class:"mt-1.5 text-[13px] text-warn",children:["Must be greater than ",e.exclusive_min,"."]})}function uo({id:e,field:t,value:o,invalid:r}){let i=t.unlimited_value!==void 0&&o===t.unlimited_value,s=n(co,{id:e,value:i?void 0:o,field:t,invalid:r,disabled:i});if(t.null_label!==void 0){let a=o===null?"n":o===void 0?"d":"s",d=typeof t.default=="number"?t.default:300;return n("div",{children:[n(Y,{id:e,label:t.label,value:a,options:[["d","Default"],["s","Seconds"],["n",t.null_label]],onPick:f=>q(t.path,f==="d"?void 0:f==="n"?null:d)}),a==="s"?n("div",{class:"mt-2.5",children:[s,n(mt,{field:t,value:o})]}):null]})}return t.unlimited_value!==void 0?n("div",{children:[n("div",{class:"flex flex-wrap items-center gap-x-4",children:[s,n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm text-body",children:[n("input",{type:"checkbox",class:"size-[18px] accent-brand-solid",checked:i,onChange:a=>q(t.path,a.currentTarget.checked?t.unlimited_value:void 0)}),"Unlimited"]})]}),n(mt,{field:t,value:o})]}):n("div",{children:[s,n(mt,{field:t,value:o})]})}function po({id:e,field:t,value:o}){if(t.unset_label!==void 0){let r=[["d","Default"]];return t.allow_true!==!1&&r.push(["t","On"]),r.push(["f","Off"]),n("div",{children:[n(Y,{id:e,label:t.label,value:o===void 0?"d":o?"t":"f",options:r,onPick:i=>q(t.path,i==="d"?void 0:i==="t")}),n("p",{class:"mt-1.5 text-xs text-faint",children:["Default: ",t.unset_label]})]})}return n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:e,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:(o??t.default??!1)===!0,onChange:r=>q(t.path,r.currentTarget.checked)}),t.label]})}function fo({id:e,field:t,value:o,invalid:r}){if(t.type==="bool")return n(po,{id:e,field:t,value:o});if(t.type==="number")return n(uo,{id:e,field:t,value:o,invalid:r});if(t.type==="list")return n(W,{id:e,values:Array.isArray(o)?o:[],placeholder:t.placeholder,onChange:i=>q(t.path,i.length?i:void 0)});if(t.type==="select"){let i=t.options.map(s=>Array.isArray(s)?s:[s,s]);return t.unset_label!==void 0&&i.unshift(["",t.unset_label]),n(ne,{id:e,options:i,invalid:r,value:o??(t.unset_label!==void 0?"":t.default??(Array.isArray(t.options[0])?t.options[0][0]:t.options[0])),onChange:s=>q(t.path,s===""?void 0:s)})}return n("input",{id:e,type:"text",value:o??"",placeholder:t.placeholder,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${r?"input-invalid":""}`,onInput:i=>q(t.path,i.currentTarget.value===""?void 0:i.currentTarget.value)})}function I({field:e}){let t=e.path.join("."),o=lo(e),r=Ae(u.config,e.path),i=O(t),s=it(e),a=tn(e),d=e.type==="bool"&&e.unset_label===void 0,f=e.default!==void 0&&!s,l=Array.isArray(e.default)?e.default.join(", "):String(e.default??"");return n("div",{id:`row-${o}`,class:`relative pt-5 pb-1 ${s?"before:absolute before:top-6 before:-left-2.5 before:bottom-2 before:w-[3px] before:rounded before:bg-brand before:content-[''] sm:before:-left-3.5":""}`,children:[n("div",{class:"flex min-h-6 items-center gap-2.5",children:[d?null:n("label",{for:o,class:`text-sm font-semibold text-ink ${a?"opacity-50":""}`,children:[e.label,e.required?n("span",{class:"text-danger",children:" *"}):null]}),d?null:n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:t}),f&&!d?n("span",{class:"rounded border border-line px-1.5 text-[11px] leading-4 font-medium text-faint",children:"default"}):null,n("span",{class:"flex-1"}),s?n("button",{type:"button",class:"focus-ring min-h-8 cursor-pointer rounded px-1 text-[13px] font-medium text-brand hover:underline",title:`Reset to default${e.default!==void 0?` (${l})`:""}`,"aria-label":`Reset ${e.label} to default`,onClick:()=>g(p=>st(p.config,e.path)),children:"Reset"}):null]}),e.help?n("p",{class:`mt-0.5 max-w-prose text-[13px] leading-5 text-muted ${a?"opacity-50":""}`,children:e.help}):null,n("fieldset",{disabled:a,class:`m-0 mt-2.5 min-w-0 max-w-[520px] border-0 p-0 ${a?"opacity-50":""}`,children:n(fo,{id:o,field:e,value:r,invalid:i?.level==="error"})}),a?n("p",{class:"mt-1.5 text-xs text-faint",children:nn(e)}):null,n(G,{issue:i})]})}var mo=[["","Choose reach"],["own","own"],["tenant","tenant"],["all","all"]],bn=e=>Array.isArray(e)?e.length>0:y(e)&&Object.keys(e).length>0;function xn(){let e=u.config.auth;return e==="jwt"?{method:"jwt"}:y(e)?e:{method:"none"}}function N(e){g(t=>{let o={...xn()};e(o),t.config.auth=o.method==="none"?null:o})}function qe(e,t,o){o==null||o===""||typeof o!="string"&&!bn(o)?delete e[t]:e[t]=o}function ho(e){return e.roles_claim!==void 0||bn(e.roles)?"roles":e.permissions_claim!==void 0?"permissions":"default"}var _o=e=>u.issues.filter(t=>t.path===e||t.path.startsWith(`${e}.`));function _t({prefix:e}){return _o(e).map((t,o)=>n(G,{issue:t},o))}function vn({id:e,label:t,name:o,onRename:r}){let[i,s]=Z(o);return L(()=>s(o),[o]),n("div",{children:[n("label",{for:e,class:"sr-only",children:t}),n("input",{id:e,type:"text",value:i,class:"input font-mono",autocomplete:"off",spellcheck:!1,onInput:a=>s(a.currentTarget.value),onChange:a=>{r(a.currentTarget.value.trim())||s(o)}})]})}var Fe=({children:e})=>n("span",{class:"mb-1.5 block text-xs font-medium text-faint",children:e});function yn(e,t,o){return!o||o!==t&&o in e?null:Object.fromEntries(Object.entries(e).map(([r,i])=>[r===t?o:r,i]))}function kn({sec:e}){let t=xn(),o=t.method??"none",r=ho(t);return n("div",{class:"space-y-6",children:[n(A,{title:"Method",children:n(pt,{name:"auth-method",label:"Authentication method",value:o,options:[["none","None","Every request is accepted. For local development."],["jwt","JWT","Verify bearer tokens signed with JWT_SECRET_KEY."],["custom","Custom","Your own BaseAuth subclass decides who is calling."]],onPick:i=>N(s=>{s.method=i,i==="custom"?s.path=s.path??"":delete s.path})})}),o==="custom"?n(A,{title:"Class",children:n("div",{class:"panel",children:n(V,{id:"auth-path",label:"Auth class",path:"auth.path",value:t.path,placeholder:"auth.custom:MyAuth",help:"Import path of a BaseAuth subclass, as module:attribute.",issue:O("auth.path"),onInput:i=>N(s=>{s.path=i})})})}):null,o!=="none"?n(w,{children:[n(A,{title:"Permission source",children:n(pt,{name:"auth-source",label:"Permission source",value:r,options:[["default","Default","Every signed-in user, own data only."],["roles","Roles from a claim","Map the roles in the token to permissions."],["permissions","Permissions from a claim","The token already lists the permissions."]],onPick:i=>N(s=>{delete s.roles_claim,delete s.permissions_claim,delete s.roles,delete s.default_role,i==="roles"&&(s.roles_claim="roles",s.roles={member:{permissions:[],reach:"own"}}),i==="permissions"&&(s.permissions_claim="permissions")})})}),r==="roles"?n(bo,{auth:t,registry:e.registry}):null,r==="permissions"?n(A,{title:"Claim",children:n("div",{class:"panel",children:n(V,{id:"auth-permissions-claim",label:"Permissions claim",path:"auth.permissions_claim",value:t.permissions_claim,placeholder:"permissions",issue:O("auth.permissions_claim"),help:"Token claim holding a list of permissions, such as graph:invoke.",onInput:i=>N(s=>{s.permissions_claim=i})})})}):null,n(A,{title:"Tenants",children:n("div",{class:"panel divide-y divide-line",children:[n("div",{class:"px-5 py-4",children:[n(fe,{label:"Tenant claims",path:"auth.tenant_claims",id:"auth-tenant-claims",help:"Token claims that name the tenant, such as org_id. Rows are separated by them."}),n("div",{class:"mt-3",children:n(W,{id:"auth-tenant-claims",values:t.tenant_claims??[],placeholder:"org_id",onChange:i=>N(s=>qe(s,"tenant_claims",i))})}),n(_t,{prefix:"auth.tenant_claims"})]}),r==="roles"?n(go,{auth:t}):null]})}),n(xo,{auth:t})]}):null]})}function go({auth:e}){let t=Object.keys(y(e.roles)?e.roles:{});return n(ut,{children:[n(fe,{label:"Default role",path:"auth.default_role",id:"auth-default-role",help:"Given to a signed-in user whose token carries no role."}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n(ne,{id:"auth-default-role",value:e.default_role??"",options:[["","None"],...t.map(o=>[o,o])],onChange:o=>N(r=>qe(r,"default_role",o))}),n(G,{issue:O("auth.default_role")})]})]})}function bo({auth:e,registry:t}){let o=y(e.roles)?e.roles:{},r=Object.entries(o),i=Object.keys(o),s=["*",...Object.entries(t||{}).flatMap(([p,h])=>[`${p}:*`,...h.map(c=>`${p}:${c}`)])],a=p=>N(h=>{h.roles=p}),d=(p,h)=>a({...o,[p]:{...o[p],...h}}),f=()=>{let p=r.length+1;for(;`role${p}`in o;)p++;a({...o,[`role${p}`]:{permissions:[],reach:"own"}})},l=(p,h)=>{let c=yn(o,p,h);return c?(N(m=>{m.roles=Object.fromEntries(Object.entries(c).map(([x,$])=>[x,$.inherits?{...$,inherits:$.inherits.map(k=>k===p?h:k)}:$])),m.default_role===p&&(m.default_role=h),y(m.system_identities)&&(m.system_identities=Object.fromEntries(Object.entries(m.system_identities).map(([x,$])=>[x,{...$,roles:($.roles??[]).map(k=>k===p?h:k)}])))}),!0):!1};return n(A,{title:"Roles",children:n("div",{class:"panel",children:[n("div",{class:"border-b border-line",children:n(V,{id:"auth-roles-claim",label:"Roles claim",path:"auth.roles_claim",value:e.roles_claim,placeholder:"roles",issue:O("auth.roles_claim"),help:"Token claim holding the user's list of role names.",onInput:p=>N(h=>{h.roles_claim=p})})}),r.length?n("div",{class:"divide-y divide-line",children:r.map(([p,h],c)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_8rem_auto] items-start gap-3",children:[n(vn,{id:`role-name-${c}`,label:"Role name",name:p,onRename:m=>l(p,m)}),n(ne,{id:`role-reach-${c}`,value:h.reach??"",options:mo,invalid:O(`auth.roles.${p}.reach`)?.level==="error",onChange:m=>d(p,{reach:m})}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove role ${p}`,onClick:()=>a(Object.fromEntries(r.filter(([m])=>m!==p))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Fe,{children:"Permissions"}),n(W,{id:`role-permissions-${c}`,values:h.permissions??[],placeholder:"graph:invoke, threads:*, *",suggestions:s,onChange:m=>d(p,{permissions:m})})]}),n("div",{children:[n(Fe,{children:"Inherits"}),n(W,{id:`role-inherits-${c}`,values:h.inherits??[],placeholder:"another role",suggestions:i.filter(m=>m!==p),onChange:m=>{let x={...h};qe(x,"inherits",m),a({...o,[p]:x})}})]}),n(_t,{prefix:`auth.roles.${p}`})]},c))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"No roles yet. Add one to use roles."}),n("div",{class:"flex items-center justify-between gap-4 border-t border-line px-5 py-3",children:[n("p",{class:"text-[13px] text-muted",children:["Reach: ",n("code",{class:"font-mono",children:"own"})," the user's rows, ",n("code",{class:"font-mono",children:"tenant"})," every row of their tenant, ",n("code",{class:"font-mono",children:"all"})," every row."]}),n("button",{type:"button",class:"btn btn-ghost",onClick:f,children:[n(_,{name:"plus"}),"Add role"]})]})]})})}function xo({auth:e}){let t=y(e.system_identities)?e.system_identities:{},o=Object.entries(t),r=Object.keys(y(e.roles)?e.roles:{}),i=l=>N(p=>qe(p,"system_identities",l)),s=(l,p)=>i({...t,[l]:{...t[l],...p}}),a=()=>{let l=o.length+1;for(;`job${l}`in t;)l++;i({...t,[`job${l}`]:{roles:[],tenant:{}}})},d=l=>Object.entries(l??{}).map(([p,h])=>`${p}=${h}`),f=l=>Object.fromEntries(l.filter(p=>p.includes("=")).map(p=>[p.slice(0,p.indexOf("=")).trim(),p.slice(p.indexOf("=")+1).trim()]));return n(A,{title:"System identities",children:n("div",{class:"panel",children:[o.length?n("div",{class:"divide-y divide-line",children:o.map(([l,p],h)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3",children:[n(vn,{id:`system-name-${h}`,label:"Identity name",name:l,onRename:c=>{let m=yn(t,l,c);return m&&i(m),!!m}}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${l}`,onClick:()=>i(Object.fromEntries(o.filter(([c])=>c!==l))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Fe,{children:"Roles"}),n(W,{id:`system-roles-${h}`,values:p.roles??[],placeholder:"member",suggestions:r,onChange:c=>s(l,{roles:c})})]}),n("div",{children:[n(Fe,{children:"Tenant, as claim=value"}),n(W,{id:`system-tenant-${h}`,values:d(p.tenant),placeholder:"org_id=acme",onChange:c=>s(l,{tenant:f(c)})})]}),n(_t,{prefix:`auth.system_identities.${l}`})]},h))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"None. Scheduled jobs and other non-user callers can run as one."}),n("div",{class:"flex justify-end border-t border-line px-5 py-3",children:n("button",{type:"button",class:"btn btn-ghost",onClick:a,children:[n(_,{name:"plus"}),"Add identity"]})})]})})}var He=[["memory","Memory","This process only"],["redis","Redis","Shared. Redis, Valkey, Dragonfly, KeyDB"],["custom","Custom","Your BaseCacheStore class"]],vo={requests:100,window:60,by:"ip"},Ue=()=>y(u.config.cache_store)?u.config.cache_store:{};function gt(e){g(t=>{let o=y(t.config.cache_store)?t.config.cache_store:{};e(o),Object.keys(o).length?t.config.cache_store=o:delete t.config.cache_store})}var yo=()=>{let e=Ue().rate_limit;return y(e)&&e.enabled!==!1},ko=()=>{let e=Ue().token_revocation;return e===!0||y(e)&&e.enabled!==!1};function wo(e){gt(t=>{delete t.url,delete t.path,e==="memory"?delete t.type:t.type=e})}function Co(e){gt(t=>{e?y(t.rate_limit)?delete t.rate_limit.enabled:t.rate_limit={...vo}:delete t.rate_limit})}function $o(e){gt(t=>{e?y(t.token_revocation)?delete t.token_revocation.enabled:t.token_revocation=!0:delete t.token_revocation})}var _n=(e,t)=>g(o=>{je(o.config,e,t)});function So({value:e,onPick:t}){let o=(r,i)=>{let s=r.key==="ArrowRight"||r.key==="ArrowDown"?1:r.key==="ArrowLeft"||r.key==="ArrowUp"?-1:0;if(!s)return;r.preventDefault();let a=He[(i+s+He.length)%He.length][0];t(a),requestAnimationFrame(()=>document.getElementById(`cache-type-${a}`)?.focus())};return n("div",{role:"radiogroup","aria-label":"Cache store type",class:"grid gap-3 sm:grid-cols-3",children:He.map(([r,i,s],a)=>{let d=r===e;return n("button",{type:"button",role:"radio",id:`cache-type-${r}`,"aria-checked":d?"true":"false",tabIndex:d?0:-1,onKeyDown:f=>o(f,a),class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 motion-reduce:transition-none ${d?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{d||t(r)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${d?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:d?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:i}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:s})]})]},r)})})}function he({warn:e,children:t}){return n("p",{class:`mt-3 flex items-start gap-1.5 text-[13px] leading-5 ${e?"text-warn":"text-muted"}`,children:[e?n(_,{name:"alert",class:"mt-0.5 size-3.5"}):null,n("span",{children:t})]})}function gn({id:e,title:t,text:o,on:r,onChange:i}){return n("div",{class:"flex items-center gap-4",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-base font-semibold text-ink",children:t}),n("p",{class:"mt-0.5 text-[13px] leading-5 text-muted",children:o})]}),n(dt,{id:e,on:r,label:t,onChange:i})]})}var To=[["ip","IP"],["user","User"],["global","Global"]];function Oo({field:e,memory:t}){let[o,r]=Z(!1),i=Ue().rate_limit,s=y(i)&&i.by||"ip",a=!(y(i)&&i.fail_open===!1),d=["trusted_proxy_headers","trusted_proxy_hops","trusted_proxies","exclude_paths"];return n(w,{children:[t?n(he,{warn:!0,children:"Counts per worker with the Memory store."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:e("cache_store.rate_limit.requests")}),n(I,{field:e("cache_store.rate_limit.window")}),n("div",{class:"pt-5",children:[n("span",{id:"cache-by-label",class:"block text-sm font-semibold text-ink",children:"Limit by"}),n("div",{class:"mt-2.5",children:n(Y,{id:"f-cache_store-rate_limit-by",label:"Limit by",options:To,value:s,onPick:f=>_n(["cache_store","rate_limit","by"],f)})}),n(G,{issue:O("cache_store.rate_limit.by")})]}),n("div",{class:"pt-5",children:[n("span",{id:"cache-fail-open-label",class:"block text-sm font-semibold text-ink",children:"Fail open"}),n("div",{class:"mt-2.5 flex items-center gap-3",children:[n(dt,{id:"f-cache_store-rate_limit-fail_open",on:a,label:"Fail open",onChange:f=>_n(["cache_store","rate_limit","fail_open"],f)}),n("p",{class:"m-0 text-[13px] leading-5 text-muted",children:a?"Allow requests if the store errors.":"Deny with 503 if the store errors."})]})]})]}),n("div",{class:"mt-5 border-t border-line pt-1",children:[n("button",{type:"button","aria-expanded":o?"true":"false","aria-controls":"cache-advanced",class:"focus-ring flex min-h-11 cursor-pointer items-center gap-2 rounded font-medium text-muted hover:text-ink",onClick:()=>r(!o),children:[n(_,{name:"chevronRight",class:`size-4 transition-transform duration-150 motion-reduce:transition-none ${o?"rotate-90":""}`}),"Advanced"]}),o?n("div",{id:"cache-advanced",children:d.map(f=>n(I,{field:e(`cache_store.rate_limit.${f}`)},f))}):null]})]})}var oe=[["rate","Rate limiting"],["revoke","Token revocation"]];function wn({sec:e}){let[t,o]=Z("rate"),r=Ue().type||"memory",i=l=>e.fields.find(p=>p.path.join(".")===l),s={rate:yo(),revoke:ko()},a=r==="memory",d={rate:!!O("cache_store.rate_limit"),revoke:!!O("cache_store.token_revocation")},f=(l,p)=>{let h=l.key==="ArrowRight"?1:l.key==="ArrowLeft"?-1:0,c=l.key==="Home"?0:l.key==="End"?oe.length-1:h?(p+h+oe.length)%oe.length:-1;c<0||(l.preventDefault(),o(oe[c][0]),document.getElementById(`cache-tab-${oe[c][0]}`)?.focus())};return n("div",{children:[n(A,{title:"Store type",children:[n(So,{value:r,onPick:wo}),r==="redis"?n(I,{field:i("cache_store.url")}):null,r==="custom"?n(I,{field:i("cache_store.path")}):null,a?n("p",{class:"mt-3 mb-0 text-[13px] leading-5 text-muted",children:"Not shared between workers: each worker keeps its own locks, limits and revocations."}):null]}),n("div",{role:"tablist","aria-label":"Cache store features",class:"mt-8 flex gap-1 border-b border-line",children:oe.map(([l,p],h)=>{let c=t===l;return n("button",{type:"button",role:"tab",id:`cache-tab-${l}`,"aria-selected":c?"true":"false","aria-controls":`cache-panel-${l}`,tabIndex:c?0:-1,onKeyDown:m=>f(m,h),class:`focus-ring -mb-px flex min-h-11 cursor-pointer items-center gap-2 border-b-2 px-3 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none ${c?"border-brand-solid text-ink":"border-transparent text-muted hover:text-ink"}`,onClick:()=>o(l),children:[p,n(te,{class:s[l]?"bg-brand-soft text-brand":"bg-raised text-muted",children:s[l]?"On":"Off"})]},l)})}),n("div",{role:"tabpanel",id:`cache-panel-${t}`,"aria-labelledby":`cache-tab-${t}`,class:"pt-5",children:t==="rate"?n(w,{children:[n(gn,{id:"cache-rate-limit",title:"Rate limiting",on:s.rate,onChange:Co,text:"Cap how many requests one caller can make."}),s.rate?n(Oo,{field:i,memory:a}):d.rate?n(he,{warn:!0,children:"Off: one caller can exhaust the service."}):null]}):n(w,{children:[n(gn,{id:"cache-token-revocation",title:"Token revocation",on:s.revoke,onChange:$o,text:"Reject revoked tokens before they expire (10xgraph revoke)."}),s.revoke?n(w,{children:[a?n(he,{warn:!0,children:"10xgraph revoke cannot reach an in-memory store; pick Redis or Custom."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:i("cache_store.token_revocation.token_lifetime")}),n(I,{field:i("cache_store.token_revocation.user_ttl")})]}),n(he,{children:"Fails closed: while the cache store is unreachable, authenticated requests get 503 and the server will not start."})]}):d.revoke?n(he,{warn:!0,children:"Off: a leaked token works until it expires."}):null]})})]})}function Cn({sec:e}){let t=e.fields[0].path[0],o=e.choices.map(p=>p[0]),r=u.config[t],i=t==="thread_name_generator"?"random":"",s=r===void 0?i:typeof r=="string"&&o.includes(r)?r:"custom",a=[...t==="id_generator"?[["","Not set (the graph's own generator)"]]:[],...e.choices.map(([p,h])=>[p,t==="thread_name_generator"&&p==="random"?`${h} (default)`:h]),["custom","Custom (module:attr)"]],d=s===""?"The graph keeps the generator it was compiled with.":s==="custom"?"Your own class, as module:attribute.":e.choices.find(p=>p[0]===s)?.[2],f=p=>g(h=>{p===i?delete h.config[t]:h.config[t]=p==="custom"?"":p}),l=`f-${t}`;return n("div",{children:[n(A,{title:"Choice",children:n("div",{class:"max-w-[520px]",children:[n("label",{for:`${l}-select`,class:"sr-only",children:e.title}),n(ne,{id:`${l}-select`,options:a,value:s,onChange:f}),n("p",{class:"mt-1.5 text-[13px] text-muted",children:d})]})}),s==="custom"?n(A,{title:"Class",children:n("div",{class:"panel",children:n(V,{id:l,label:e.fields[0].label,path:t,value:typeof r=="string"?r:"",placeholder:e.fields[0].placeholder,help:e.fields[0].help,issue:O(t),onInput:p=>g(h=>{h.config[t]=p})})})}):null]})}function $n(){let e=Array.isArray(u.config.remote_tools)?u.config.remote_tools:[],t=()=>{g(o=>{e.push({node:"",name:"",description:"",parameters:{type:"object",properties:{},required:[]}}),o.config.remote_tools=e}),requestAnimationFrame(()=>document.getElementById(`tool-${e.length-1}-name`)?.focus())};return e.length?n("div",{class:"space-y-4",children:[e.map((o,r)=>n(Io,{tools:e,tool:o,index:r},r)),n("button",{type:"button",onClick:t,class:"focus-ring flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-line-strong text-sm font-medium text-muted transition-colors duration-150 hover:border-brand hover:text-brand",children:[n(_,{name:"plus"}),"Add remote tool"]})]}):n("div",{class:"rounded-xl border border-dashed border-line-strong bg-surface px-6 py-10 text-center",children:[n("div",{class:"mx-auto flex size-11 items-center justify-center rounded-full bg-raised text-faint",children:n(_,{name:"wrench",class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:"No remote tools yet"}),n("p",{class:"mx-auto mt-1 max-w-sm text-sm leading-6 text-muted",children:"Declare a tool here and the client that calls the API runs it, for example to read the user's location."}),n("button",{type:"button",class:"btn btn-outline mt-5",onClick:t,children:[n(_,{name:"plus"}),"Add remote tool"]})]})}function Io({tools:e,tool:t,index:o}){let r=u.toolParams[o]??{text:JSON.stringify(t.parameters??{},null,2),error:null},i=a=>g(()=>{e[o]={...e[o],...a}}),s=a=>`remote_tools[${o}].${a}`;return n("section",{class:"panel",children:[n("div",{class:"flex items-center gap-3 border-b border-line px-5 py-3",children:[n(_,{name:"wrench",class:"size-4 text-faint"}),n("span",{class:"min-w-0 flex-1 truncate font-mono text-sm font-medium text-ink",children:t.name||`tool_${o+1}`}),t.node?n("span",{class:"rounded-md border border-line bg-raised px-1.5 py-0.5 font-mono text-xs text-muted",children:t.node}):null,n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${t.name||"tool"}`,onClick:()=>g(a=>{e.splice(o,1),a.toolParams={}}),children:n(_,{name:"trash"})})]}),n("div",{class:"divide-y divide-line",children:[n(V,{id:`tool-${o}-name`,label:"Tool name",path:s("name"),value:t.name,placeholder:"get_location",onInput:a=>i({name:a})}),n(V,{id:`tool-${o}-node`,label:"Graph node",path:s("node"),value:t.node,placeholder:"MAIN",help:"The node whose model may call this tool.",onInput:a=>i({node:a})}),n(V,{id:`tool-${o}-description`,label:"Description",path:s("description"),value:t.description,placeholder:"Returns the user's current city",help:"What the model reads when deciding to call it.",onInput:a=>i({description:a})}),n("div",{class:"px-5 py-4",children:[n(fe,{label:"Parameters",path:s("parameters"),help:"JSON schema of the arguments. type must be object.",id:`tool-${o}-params`}),n("textarea",{id:`tool-${o}-params`,rows:7,spellcheck:!1,value:r.text,class:`input mt-3 h-auto py-2.5 font-mono text-[13px] leading-5 ${r.error?"input-invalid":""}`,onInput:a=>{let d=a.currentTarget.value;g(f=>{try{e[o]={...e[o],parameters:JSON.parse(d)},f.toolParams[o]={text:d,error:null}}catch(l){f.toolParams[o]={text:d,error:l.message}}})}}),n(G,{issue:r.error?{level:"error",message:r.error}:null})]})]})]})}var _e=({children:e,code:t})=>n("h1",{id:"page-title",tabindex:-1,class:"flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-semibold tracking-tight text-ink focus:outline-none",children:[e,t?n("code",{class:"rounded border border-line bg-raised px-1.5 font-mono text-xs font-normal text-faint",children:t}):null]}),bt=({children:e})=>n("p",{class:"mt-1.5 max-w-[62ch] text-[15px] leading-6 text-muted",children:e}),Sn=({children:e})=>n("h2",{class:"mt-10 mb-2 eyebrow",children:e});function Tn({id:e,text:t,warn:o,compact:r}){let i=pe(e);return i?n("a",{href:`#${e}`,onClick:s=>{s.preventDefault(),R(e)},class:`focus-ring group flex items-center gap-3.5 border-t border-line py-2 last:border-b ${r?"min-h-12":"min-h-14"}`,children:[n(_,{name:K[e]||"box",class:"size-[18px] text-faint"}),n("span",{class:`min-w-0 flex-1 ${r?"flex flex-wrap items-baseline gap-x-4":""}`,children:[n("span",{class:`block text-sm text-ink group-hover:text-brand ${r?"min-w-40 font-medium":"font-semibold"}`,children:i.title}),n("span",{class:`block text-[13px] leading-5 ${o?"text-warn":"text-muted"}`,children:t??i.description})]}),n(_,{name:"chevronRight",class:"size-4 text-faint"})]}):null}function In(e){return`Not set. ${E(e)?.off_summary||""}`.trim()}function Eo(){let e=u.config.auth,t=e==="jwt"?"jwt":y(e)?e.method??"none":"none";if(t==="none")return{text:In("auth")};let o=y(e)&&y(e.roles)?Object.keys(e.roles).length:0;return{text:`${t==="jwt"?"JWT bearer tokens":`Custom backend${e.path?`: ${e.path}`:""}`}${o?`, ${o} ${o===1?"role":"roles"}`:""}.`}}function Ao(){let e=y(u.config.cache_store)?u.config.cache_store:{},t=[`${e.type||"memory"} store`],o=e.rate_limit;if(y(o)&&o.enabled!==!1){let i={ip:"per IP",user:"per user",global:"across all clients"}[o.by??"ip"]||o.by;t.push(`${o.requests??100} requests per ${o.window??60} s, ${i}`)}let r=e.token_revocation;return(r===!0||y(r)&&r.enabled!==!1)&&t.push("token revocation on"),{text:`${t.join("; ")}.`}}function jo(){let e=u.config.observability;if(e==null)return{text:In("observability")};let t=[];return de(["observability","logfire","enabled"])&&t.push("Logfire"),de(["observability","langsmith","enabled"])&&t.push("LangSmith"),{text:`Level ${de(["observability","level"])}${t.length?`, sending to ${t.join(" and ")}`:", no destination chosen yet"}.`}}function On(){let e=E("core"),t=on(),o=e?X("core").filter(s=>!e.fields.some(a=>a.path.join(".")===s.path)):[],r=[["auth",Eo()],["cache_store",Ao()],["observability",jo()]],i=u.nav.flatMap(s=>s.items).filter(s=>s!=="get-running");return n(w,{children:[n(_e,{children:t?"You're ready to run":"One step to run your agent"}),n(bt,{children:z["get-running"].description}),n("div",{class:`mt-4 flex items-center gap-2 font-medium ${t?"text-ok":"text-muted"}`,children:[n(_,{name:t?"checkCircle":"circle",class:"size-[18px]"}),"Required: ",t?"1":"0"," of 1 done"]}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((s,a)=>n(ft,{issue:s},a))}):null,n("div",{class:"mt-2",children:e?ht(e.fields).flatMap(s=>s.fields).map(s=>n(I,{field:s},s.path.join("."))):null}),t?n("div",{class:"mt-7 border-t border-line pt-5",children:[n("div",{class:"flex items-center gap-2.5 font-medium text-ok",children:[n(_,{name:"checkCircle",class:"size-[18px]"}),"Ready for 10xgraph dev"]}),n("div",{class:"mt-3 flex max-w-[420px] items-center gap-2 rounded-lg border border-line-strong bg-sunken py-0 pr-1 pl-3.5",children:[n("code",{class:"flex-1 font-mono text-[13px] font-medium text-ink",children:"10xgraph dev"}),u.copied?n("span",{class:"text-xs text-ok",children:"Copied"}):null,n("button",{type:"button",class:"icon-btn",onClick:un,"aria-label":u.copied?"Copied 10xgraph dev":"Copy command 10xgraph dev",children:n(_,{name:"copy",class:"size-[18px]"})})]}),n("p",{class:"mt-3 text-muted",children:"Everything else already has working defaults."})]}):n("p",{class:"mt-4 text-xs text-faint",children:"Add the agent graph above, then run 10xgraph dev."}),n(Sn,{children:"Before production (optional)"}),n("div",{children:r.map(([s,a])=>n(Tn,{id:s,text:a.text,warn:a.warn},s))}),n(Sn,{children:"What you can configure"}),n("div",{children:i.map(s=>n(Tn,{id:s,compact:!0},s))})]})}function We({vars:e}){return n("dl",{class:"mb-3 grid gap-x-4 gap-y-1 sm:grid-cols-[minmax(0,260px)_minmax(0,1fr)]",children:e.map(t=>[n("dt",{class:"py-0.5 font-mono text-xs font-medium break-all text-ink",children:t.name},`${t.name}-n`),n("dd",{class:"m-0 pb-2 text-[13px] text-muted sm:py-0.5",children:t.purpose},`${t.name}-p`)])})}function Po({vars:e,open:t}){return e?.length?n("details",{class:"group mt-9 border-t border-line pt-1",open:t,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform group-open:rotate-90"}),"Set in .env"]}),n("p",{class:"mb-2 text-xs text-faint",children:"Read only. The editor never reads .env, so only names are shown."}),n(We,{vars:e})]}):null}function En({sec:e}){return e.widget==="auth"?n(kn,{sec:e}):e.widget==="remote_tools"?n($n,{}):e.widget==="cache_store"?n(wn,{sec:e}):e.widget==="id_generator"||e.widget==="thread_name_generator"?n(Cn,{sec:e}):null}function Mo(e){let t=new Set([...e.fields.map(i=>i.path.join(".")),...Object.keys(ot)]),o=new Set(["cache_store.rate_limit","cache_store.token_revocation"]),r=i=>i.startsWith("auth.roles.")||i.startsWith("auth.system_identities.");return X(e.id).filter(i=>!t.has(i.path)&&!(e.id==="cache_store"&&o.has(i.path))&&!(e.id==="auth"&&r(i.path)))}function Ro({sec:e}){let t=Pe(e),o=e.widget&&(t||!e.optional);return n(w,{children:[o?n("div",{class:"mt-6",children:n(En,{sec:e})}):null,n("div",{class:e.optional&&!t?"opacity-80":"",children:ht(e.fields.filter(r=>!r.managed)).map((r,i)=>n("div",{children:[r.name?n("h3",{class:`eyebrow ${i===0?"mt-6":"mt-8 border-t border-line pt-4"}`,children:r.name}):null,r.fields.map(s=>n(I,{field:s},s.path.join(".")))]},r.name))})]})}function No({sec:e}){let t=Pe(e),o=Mo(e);return n(w,{children:[n(_e,{code:e.key,children:e.title}),n(bt,{children:e.description}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((r,i)=>n(ft,{issue:r},i))}):null,e.optional?n("div",{class:"mt-5 flex flex-wrap items-center gap-4",children:[n(Y,{label:`Settings for ${e.title}`,value:t?"c":"d",options:[["d","Use defaults"],["c","Customize"]],onPick:r=>cn(e,r==="c")}),n("p",{class:"m-0 min-w-56 flex-1 text-[13px] text-muted",children:t?`Saved in 10xgraph.json under ${e.key}.`:e.off_summary})]}):null,e.notes?.length?n("div",{class:"mt-5",children:n(me,{kind:"info",lines:e.notes})}):null,n(Ro,{sec:e}),n(Po,{vars:e.env})]})}function Do(){let e=u.schema.environment||[];return n(w,{children:[n(_e,{children:z.environment.title}),n(bt,{children:"These variables are set in .env or your deployment environment. The editor never reads that file, so only names and purposes are shown."}),e.map(t=>n("section",{children:[n("h2",{class:"mt-8 mb-2 text-sm font-semibold text-ink",children:t.title}),t.note?n("p",{class:"mb-2 text-xs text-faint",children:t.note}):null,n(We,{vars:t.vars})]},t.title))]})}var An=e=>String(e??"").toLowerCase();function Lo(e){let t=i=>e.every(s=>An(i).includes(s)),o=[];for(let i of u.sections){let s=t(`${i.title} ${i.description} ${i.key||""}`),a=p=>s||t(`${p.label} ${p.help||""} ${p.path.join(".")}`),d=i.fields.filter(p=>!p.managed&&a(p)),f=(i.env||[]).filter(p=>t(`${p.name} ${p.purpose}`)),l=!!i.widget&&(s||i.fields.some(p=>p.managed&&a(p)));(d.length||f.length||l)&&o.push({id:i.id,sec:i,fields:d,env:f,widget:l})}let r=(u.schema.environment||[]).map(i=>({title:i.title,vars:i.vars.filter(s=>t(`${s.name} ${s.purpose} ${i.title}`))})).filter(i=>i.vars.length);return{results:o,envGroups:r}}function zo(){let e=An(u.query).split(/\s+/).filter(Boolean),{results:t,envGroups:o}=Lo(e),r=t.reduce((i,s)=>i+s.fields.length+s.env.length+(s.widget?1:0),0)+o.reduce((i,s)=>i+s.vars.length,0);return r?n(w,{children:[n(_e,{code:u.query,children:[r," ",r===1?"setting matches":"settings match"]}),t.map(({id:i,sec:s,fields:a,env:d,widget:f})=>n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:K[i]||"box",class:"size-4"}),s.title,n("a",{href:`#${i}`,onClick:l=>{l.preventDefault(),R(i)},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),f?n("div",{class:"mt-4",children:n(En,{sec:s})}):null,a.map(l=>n(I,{field:l},l.path.join("."))),d.length?n("details",{class:"group mt-5",open:!0,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform group-open:rotate-90"}),"Set in .env"]}),n(We,{vars:d})]}):null]},i)),o.length?n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:"terminal",class:"size-4"}),z.environment.title,n("a",{href:"#environment",onClick:i=>{i.preventDefault(),R("environment")},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),o.map(i=>n("div",{children:[n("h3",{class:"mt-4 mb-1.5 text-sm font-semibold text-ink",children:i.title}),i.note?n("p",{class:"mb-1.5 text-xs text-faint",children:i.note}):null,n(We,{vars:i.vars})]},i.title))]}):null]}):n(w,{children:[n(_e,{children:"No matches"}),n("p",{class:"mt-8 text-muted",children:['Nothing matches "',u.query,'". Try a shorter word, or press Esc to go back.']})]})}function jn(){if(u.query.trim())return n(zo,{});if(u.page==="get-running")return n(On,{});if(u.page==="environment")return n(Do,{});let e=E(u.page);return e?n(No,{sec:e}):n(On,{})}function Ho(){let e=u.path.split(/[\\/]/).pop()||"10xgraph.json",t=u.path.slice(0,u.path.length-e.length),o=null;return u.schema?u.exists?H()?o=n(te,{class:"bg-warn-soft text-warn",children:"Unsaved"}):o=n(te,{class:"bg-ok-soft text-ok",children:"Saved"}):o=n(te,{class:"bg-brand-soft text-brand",children:"New"}):o=null,n("div",{class:"flex min-w-0 flex-1 items-center gap-2",title:u.path||void 0,children:[n(_,{name:"file",class:"size-4 text-faint"}),n("span",{class:"truncate font-mono text-[13px] font-medium text-ink",children:e}),o,t?n("span",{class:"hidden min-w-0 truncate font-mono text-xs text-faint lg:block",dir:"rtl",children:n("bdi",{children:t})}):null]})}function Pn(){let e=!!u.schema&&!u.fatal,t=u.sideOpen?"Hide sidebar":"Show sidebar";return n("header",{class:"sticky top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-3 sm:px-4",children:[n("button",{type:"button",class:"icon-btn","aria-controls":"side","aria-expanded":u.sideOpen?"true":"false","aria-label":t,title:`${t} (Ctrl/Cmd+B)`,onClick:()=>ee(!u.sideOpen),children:n(_,{name:"panel",class:"size-[18px]"})}),n("div",{class:"flex shrink-0 items-center gap-2.5",children:[n("svg",{viewBox:"0 0 48 48",class:"size-7 shrink-0 text-ink","aria-hidden":"true",children:[n("rect",{x:"4",y:"4",width:"40",height:"40",rx:"11",fill:"none",stroke:"currentColor","stroke-width":"4.4"}),n("path",{d:"M13 33L21 23L34 15L34 31L21 23",fill:"none",stroke:"currentColor","stroke-width":"3","stroke-linecap":"round","stroke-linejoin":"round"}),n("g",{fill:"currentColor",children:[n("circle",{cx:"21",cy:"23",r:"4"}),n("circle",{cx:"34",cy:"15",r:"4"}),n("circle",{cx:"34",cy:"31",r:"4"})]}),n("circle",{cx:"13",cy:"33",r:"4.6",class:"fill-brand-amber"})]}),n("div",{class:"hidden items-baseline gap-1.5 sm:flex",children:[n("span",{class:"text-[15px] font-semibold tracking-tight text-ink",children:"10xGraph"}),n("span",{class:"text-[15px] text-faint",children:"Config"})]})]}),n("span",{class:"hidden h-6 w-px shrink-0 bg-line md:block","aria-hidden":"true"}),n(Ho,{}),n("button",{type:"button",class:"btn btn-ghost","aria-label":"View 10xgraph.json","aria-haspopup":"dialog",disabled:!e,onClick:()=>dn(),children:[n(_,{name:"code",class:"size-[18px]"}),n("span",{class:"hidden md:inline",children:"View 10xgraph.json"})]}),n("button",{type:"button",class:"btn btn-primary",onClick:Re,disabled:!e||!!u.busy||u.exists&&!H()&&!u.parseError,children:[u.busy==="save"?n(_,{name:"spinner",class:"size-4 animate-spin"}):null,u.exists?"Save":"Create file",n("span",{class:"kbd hidden lg:inline-flex",children:"Ctrl S"})]})]})}function Mn(){return u.fatal?n("div",{class:"panel mt-10",children:n(mn,{icon:"xCircle",tint:"text-danger",title:u.fatal.title,text:u.fatal.text})}):u.schema?n(w,{children:[u.parseError?n("div",{class:"mb-6",children:n(me,{kind:"warn",title:"The existing file is not valid JSON",lines:[`${u.parseError}. The form starts from defaults; saving replaces the file and keeps the old one as 10xgraph.json.bak.`]})}):null,n(jn,{})]}):n("div",{class:"animate-pulse space-y-4 pt-2","aria-label":"Loading",children:[n("div",{class:"h-7 w-48 rounded-md bg-raised"}),n("div",{class:"h-4 w-80 max-w-full rounded-md bg-raised"}),[0,1,2].map(e=>n("div",{class:"h-16 rounded-xl bg-raised"},e))]})}function Fo({id:e}){let t=!!E(e)&&Xt(E(e)),o=en(e);return!t&&!o?null:n("span",{class:"flex shrink-0 gap-1",children:[t?n("span",{class:"size-2 rounded-full bg-brand",title:"Changed from default"}):null,o?n("span",{class:`size-2 rounded-full ${o.level==="error"?"bg-danger":"bg-warn"}`,title:o.level==="error"?"Needs attention":"Has a warning"}):null]})}function qo({id:e}){let t=pe(e);if(!t)return null;let o=!u.query&&u.page===e;return n("a",{href:`#${e}`,"aria-current":o?"page":void 0,onClick:r=>{r.preventDefault(),R(e)},ref:r=>{r&&o&&window.innerWidth<1024&&r.scrollIntoView({block:"nearest",inline:"nearest"})},class:`focus-ring flex min-h-10 items-center gap-2.5 rounded-lg px-3 py-1.5 font-medium transition-colors duration-150 max-lg:min-h-11 max-lg:shrink-0 max-lg:whitespace-nowrap ${o?"bg-brand-soft text-ink":"text-muted hover:bg-raised hover:text-ink"}`,children:[n(_,{name:K[e]||"box",class:`size-[18px] ${o?"text-brand":""}`}),n("span",{class:"min-w-0 flex-1",children:[n("span",{class:"block truncate",children:t.title}),t.subtitle?n("span",{class:"block truncate text-xs font-normal text-faint max-lg:hidden",children:t.subtitle}):null]}),n(Fo,{id:e})]})}function Rn(){return u.sideRender?n("aside",{id:"side","aria-label":"Settings navigation",class:`contents lg:sticky lg:top-14 lg:block lg:h-[calc(100dvh-3.5rem)] lg:overflow-y-auto lg:border-r lg:border-line lg:bg-surface lg:px-3 lg:pt-4 lg:pb-16 lg:transition-[transform,opacity] lg:duration-200 lg:ease-out ${u.sideShown?"lg:translate-x-0 lg:opacity-100":"lg:-translate-x-6 lg:opacity-0"}`,inert:!u.sideOpen,children:[n("div",{role:"search",class:"relative mx-1 mb-3 max-lg:mx-4 max-lg:mt-3",children:[n(_,{name:"search",class:"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-faint"}),n("label",{for:"q",class:"sr-only",children:"Search settings"}),n("input",{id:"q",type:"search",value:u.query,autocomplete:"off",spellcheck:!1,placeholder:"Search settings ( / )",class:"input pl-10",onInput:e=>De(e.currentTarget.value)})]}),n("nav",{"aria-label":"Pages",class:"max-lg:sticky max-lg:top-14 max-lg:z-20 max-lg:mt-3 max-lg:flex max-lg:gap-0.5 max-lg:overflow-x-auto max-lg:border-b max-lg:border-line max-lg:bg-surface max-lg:px-3 max-lg:py-1.5",children:u.nav.map(e=>n("div",{class:"max-lg:contents",children:[e.label?n("div",{class:"mt-4 mb-1 px-3 text-[11px] font-semibold tracking-wider text-faint uppercase max-lg:hidden",children:e.label}):null,e.items.map(t=>n(qo,{id:t},t))]},e.label||"top"))})]}):null}var Uo={success:["checkCircle","text-ok"],warning:["alert","text-warn"],error:["xCircle","text-danger"]};function Wo(){return n("div",{class:"pointer-events-none fixed right-4 bottom-4 left-4 z-50 flex flex-col items-end gap-2 sm:left-auto","aria-live":"polite",children:u.toasts.map(e=>{let[t,o]=Uo[e.kind];return n("div",{role:e.kind==="error"?"alert":"status",class:"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-sm shadow-pop",children:[n(_,{name:t,class:`mt-0.5 size-4 ${o}`}),n("p",{class:"min-w-0 flex-1 leading-5 text-body",children:e.message}),e.action?n("button",{type:"button",class:"shrink-0 font-medium text-brand hover:underline",onClick:()=>{ze(e.id),e.action.run()},children:e.action.label}):null,n("button",{type:"button",class:"-mr-1 shrink-0 cursor-pointer rounded text-faint hover:text-ink","aria-label":"Dismiss",onClick:()=>ze(e.id),children:n(_,{name:"x"})})]},e.id)})})}var Go=()=>/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)||document.activeElement?.isContentEditable;function Vo(){let[,e]=nt(o=>o+1,0);L(()=>{Qt(e);try{localStorage.getItem("tg-sidebar")==="hidden"&&ee(!1,{instant:!0})}catch{}let o=s=>{let a=s.ctrlKey||s.metaKey,d=s.key.toLowerCase();a&&d==="s"?(s.preventDefault(),Re()):a&&d==="b"?(s.preventDefault(),ee(!u.sideOpen)):a&&d==="k"?(s.preventDefault(),ct()):s.key==="/"&&!a&&!s.altKey&&!Go()&&!u.drawerOpen?(s.preventDefault(),ct()):s.key==="Escape"&&u.query&&!u.drawerOpen&&De("")},r=s=>{u.schema&&H()&&s.preventDefault()},i=()=>Ne();return document.addEventListener("keydown",o),window.addEventListener("beforeunload",r),window.addEventListener("hashchange",i),an(),()=>{document.removeEventListener("keydown",o),window.removeEventListener("beforeunload",r),window.removeEventListener("hashchange",i)}},[]),L(()=>{if(!u.schema||u.validated===M())return;let o=setTimeout(ln,400);return()=>clearTimeout(o)});let t=u.sideRender;return n(w,{children:[n("a",{href:"#main",class:"sr-only z-50 rounded-md bg-brand-solid px-3 py-2 text-white focus:not-sr-only focus:fixed focus:top-3 focus:left-3",children:"Skip to content"}),n(Pn,{}),n("div",{class:t?"lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:items-start":"",children:[n(Rn,{}),n("main",{id:"main",tabindex:-1,class:"min-w-0 px-4 pt-6 pb-24 focus:outline-none sm:px-8 sm:pt-8",children:n("div",{class:"mx-auto w-full max-w-[760px]",children:n(Mn,{})})})]}),n(hn,{}),n("div",{class:"sr-only",role:"status","aria-live":"polite",children:u.copied?"Copied":""}),n(Wo,{})]})}Rt(n(Vo,{}),document.getElementById("app"));
+var Ie,v,Lt,eo,J,At,Nt,Dt,et,Se,ue,Ht,ot,tt,nt,to,Ee={},Pe=[],no=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,Ae=Array.isArray;function F(e,t){for(var o in t)e[o]=t[o];return e}function rt(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function oo(e,t,o){var r,i,s,a={};for(s in t)s=="key"?r=t[s]:s=="ref"?i=t[s]:a[s]=t[s];if(arguments.length>2&&(a.children=arguments.length>3?Ie.call(arguments,2):o),typeof e=="function"&&e.defaultProps!=null)for(s in e.defaultProps)a[s]===void 0&&(a[s]=e.defaultProps[s]);return Te(e,a,r,i,null)}function Te(e,t,o,r,i){var s={type:e,props:t,key:o,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Lt,__i:-1,__u:0};return i==null&&v.vnode!=null&&v.vnode(s),s}function x(e){return e.children}function $e(e,t){this.props=e,this.context=t}function Z(e,t){if(t==null)return e.__?Z(e.__,e.__i+1):null;for(var o;tt&&J.sort(Dt),e=J.shift(),t=J.length,ro(e)}finally{J.length=Oe.__r=0}}function Ft(e,t,o,r,i,s,a,u,m,l,p){var h,c,f,y,T,w,E=r&&r.__k||Pe,k=t.length;for(m=so(o,t,E,m,k),h=0;h0?a=e.__k[s]=Te(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):e.__k[s]=a,m=s+c,a.__=e,a.__b=e.__b+1,u=null,(l=a.__i=io(a,o,m,h))!=-1&&(h--,(u=o[l])&&(u.__u|=2)),u==null||u.__v==null?(l==-1&&(i>p?c--:im?c--:c++,a.__u|=4))):e.__k[s]=null;if(h)for(s=0;s
(p?1:0)){for(i=o-1,s=o+1;i>=0||s=0?i--:s++])!=null&&(2&l.__u)==0&&u==l.key&&m==l.type)return a}return-1}function jt(e,t,o){t[0]=="-"?e.setProperty(t,o??""):e[t]=o==null?"":typeof o!="number"||no.test(t)?o:o+"px"}function Ce(e,t,o,r,i){var s,a;e:if(t=="style")if(typeof o=="string")e.style.cssText=o;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)o&&t in o||jt(e.style,t,"");if(o)for(t in o)r&&o[t]==r[t]||jt(e.style,t,o[t])}else if(t[0]=="o"&&t[1]=="n")s=t!=(t=t.replace(Ht,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+s]=o,o?r?o[ue]=r[ue]:(o[ue]=ot,e.addEventListener(t,s?nt:tt,s)):e.removeEventListener(t,s?nt:tt,s);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=o??"";break e}catch{}typeof o=="function"||(o==null||o===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&o==1?"":o))}}function Mt(e){return function(t){if(this.l){var o=this.l[t.type+e];if(t[Se]==null)t[Se]=ot++;else if(t[Se]0?e:Ae(e)?e.map(Gt):e.constructor!==void 0?null:F({},e)}function ao(e,t,o,r,i,s,a,u,m){var l,p,h,c,f,y,T,w=o.props||Ee,E=t.props,k=t.type;if(k=="svg"?i="http://www.w3.org/2000/svg":k=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),s!=null){for(l=0;l=o.__.length&&o.__.push({}),o.__[e]}function K(e){return je=1,dt(rn,e)}function dt(e,t,o){var r=ct(pe++,2);if(r.t=e,!r.__c&&(r.__=[o?o(t):rn(void 0,t),function(u){var m=r.__N?r.__N[0]:r.__[0],l=r.t(m,u);m!==l&&(r.__N=[l,r.__[1]],r.__c.setState({}))}],r.__c=C,!C.__f)){var i=function(u,m,l){if(!r.__c.__H)return!0;var p=!1,h=r.__c.props!==u;if(r.__c.__H.__.some(function(f){if(f.__N){p=!0;var y=f.__[0];f.__=f.__N,f.__N=void 0,y!==f.__[0]&&(h=!0)}}),s){var c=s.call(this,u,m,l);return p?c||h:c}return!p||h};C.__f=!0;var s=C.shouldComponentUpdate,a=C.componentWillUpdate;C.componentWillUpdate=function(u,m,l){if(this.__e){var p=s;s=void 0,i(u,m,l),s=p}a&&a.call(this,u,m,l)},C.shouldComponentUpdate=i}return r.__N||r.__}function U(e,t){var o=ct(pe++,3);!$.__s&&on(o.__H,t)&&(o.__=e,o.u=t,C.__H.__h.push(o))}function Me(e){return je=5,co(function(){return{current:e}},[])}function co(e,t){var o=ct(pe++,7);return on(o.__H,t)&&(o.__=e(),o.__H=t,o.__h=e),o.__}function uo(){for(var e;e=nn.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(Re),t.__h.some(lt),t.__h=[]}catch(o){t.__h=[],$.__e(o,e.__v)}}}$.__b=function(e){C=null,Kt&&Kt(e)},$.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),en&&en(e,t)},$.__r=function(e){Yt&&Yt(e),pe=0;var t=(C=e.__c).__H;t&&(at===C?(t.__h=[],C.__h=[],t.__.some(function(o){o.__N&&(o.__=o.__N),o.u=o.__N=void 0})):(t.__h.some(Re),t.__h.some(lt),t.__h=[],pe=0)),at=C},$.diffed=function(e){Xt&&Xt(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(nn.push(t)!==1&&Jt===$.requestAnimationFrame||((Jt=$.requestAnimationFrame)||po)(uo)),t.__H.__.some(function(o){o.u&&(o.__H=o.u,o.u=void 0)})),at=C=null},$.__c=function(e,t){t.some(function(o){try{o.__h.some(Re),o.__h=o.__h.filter(function(r){return!r.__||lt(r)})}catch(r){t.some(function(i){i.__h&&(i.__h=[])}),t=[],$.__e(r,o.__v)}}),Qt&&Qt(e,t)},$.unmount=function(e){Zt&&Zt(e);var t,o=e.__c;o&&o.__H&&(o.__H.__.some(function(r){try{Re(r)}catch(i){t=i}}),o.__H=void 0,t&&$.__e(t,o.__v))};var tn=typeof requestAnimationFrame=="function";function po(e){var t,o=function(){clearTimeout(r),tn&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(o,35);tn&&(t=requestAnimationFrame(o))}function Re(e){var t=C,o=e.__c;typeof o=="function"&&(e.__c=void 0,o()),C=t}function lt(e){var t=C;e.__c=e.__(),C=t}function on(e,t){return!e||e.length!==t.length||t.some(function(o,r){return o!==e[r]})}function rn(e,t){return typeof t=="function"?t(e):t}var Le=[{label:null,items:["get-running"]},{label:"Before production",items:["auth","cache_store","observability","metrics"]},{label:"Runs and streaming",items:["runs","websocket","ag_ui","remote_tools","media"]},{label:"Project wiring",items:["checkpointer","id_generator","thread_name_generator","store","injectq"]},{label:"Developer tools",items:["test","evaluation"]},{label:"Reference",items:["environment"]}],q={"get-running":{title:"Get running",icon:"flag",description:"Only the agent graph is required. Everything else is optional."},environment:{title:"Server environment (.env)",icon:"terminal",description:"Variables the server reads from .env. The editor never reads that file, so values are not shown."}},ut={"auth.path":"auth-path"},sn="tg-config-token",an=!1;function mo(){let e=new URLSearchParams(location.hash.slice(1)).get("token")||"";try{e&&sessionStorage.setItem(sn,e);let t=sessionStorage.getItem(sn)||"";return an=t!==""&&t===(e||t),e||t}catch{return e}}var me=mo(),pt=e=>an||!me?`#${e}`:`#token=${encodeURIComponent(me)}&page=${e}`;function ln(){let e=location.hash.slice(1);if(e.includes("=")){let t=new URLSearchParams(e);return t.get("page")||t.get("section")||""}try{return decodeURIComponent(e)}catch{return e}}var d={schema:null,sections:[],nav:[],fields:new Map,config:{},version:null,exists:!1,path:"",parseError:null,saved:"",validated:null,issues:[],page:"get-running",query:"",sideOpen:!0,sideRender:!0,sideShown:!0,drawerOpen:!1,tab:"json",copied:!1,busy:null,toolParams:{},toasts:[],fatal:null},cn=()=>{},dn=e=>{cn=e};function b(e){e&&e(d),cn()}var un=e=>JSON.parse(JSON.stringify(e)),L=()=>JSON.stringify(d.config),g=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),N=(e,t)=>t.reduce((o,r)=>g(o)?o[r]:void 0,e),fe=(e,t)=>`${e} ${t}${e===1?"":"s"}`;function oe(e,t,o){let r=e;for(let i of t.slice(0,-1))g(r[i])||(r[i]={}),r=r[i];r[t[t.length-1]]=o}var fo=new Set(["cache_store.rate_limit","cache_store.token_revocation"]);function he(e,t){let o=[],r=e;for(let i of t.slice(0,-1)){if(!g(r))return;o.push([r,i]),r=r[i]}g(r)&&delete r[t[t.length-1]];for(let i=o.length-1;i>=1;i--){let[s,a]=o[i];if(fo.has(t.slice(0,i+1).join(".")))break;if(g(s[a])&&Object.keys(s[a]).length===0)delete s[a];else break}}function mt(e){let t=N(d.config,e);return t!==void 0?t:d.fields.get(e.join("."))?.default}var ho=(e,t)=>JSON.stringify(e)===JSON.stringify(t),O=e=>d.sections.find(t=>t.id===e),W=()=>L()!==d.saved,Ne=e=>!e.optional||e.key in d.config&&d.config[e.key]!==null,re=e=>d.issues.filter(t=>t.section===e),S=e=>d.issues.find(t=>t.path===e);function _e(e=d.issues){let t=e.filter(o=>o.level==="error").length;return{errors:t,warnings:e.length-t}}var De=e=>ut[e]||`f-${e.replaceAll(".","-")}`,ft=e=>{let t=N(d.config,e.path);return t!==void 0&&!ho(t,e.default)},pn=e=>e.id==="core"?!1:e.id==="auth"?d.config.auth!=null:e.id==="metrics"?d.config.metrics?.enabled===!0:e.optional?Ne(e):e.fields.some(ft);function mn(e){let t=re(e);return t.find(o=>o.level==="error")||t[0]||null}var fn=e=>!!e.show_if&&mt(e.show_if.path)!==e.show_if.equals;function hn(e){let o=d.fields.get(e.show_if.path.join("."))?.label||e.show_if.path.join("."),r=e.show_if.equals;return`Used when ${o} is ${r===!0?"on":r===!1?"off":String(r)}.`}var ge=e=>{let t=O(e);return q[e]?{id:e,...q[e]}:t?{id:e,title:t.title,subtitle:t.subtitle,description:t.description,icon:null}:null},_n=()=>/^[\w.]+:[\w.]+$/.test(String(d.config.agent||""));async function _t(e,t,o){let r={"X-10xGraph-Token":me};o!==void 0&&(r["Content-Type"]="application/json");let i=await fetch(t,{method:e,headers:r,body:o===void 0?void 0:JSON.stringify(o)}),s={};try{s=await i.json()}catch{}return{ok:i.ok,status:i.status,data:s}}var ht=(e,t)=>b(o=>{o.fatal={title:e,text:t}});async function xn(){if(!me)return ht("Open the link printed by 10xgraph config","This page needs the session token included in that link.");let e;try{e=await _t("GET","/api/state")}catch{return ht("Cannot reach the config server","Check that 10xgraph config is still running in your terminal, then reload.")}if(!e.ok)return ht("This link has expired",e.data.error||"Restart 10xgraph config and open the new link it prints.");let t=e.data;b(o=>{o.schema=t.schema,o.sections=t.schema.sections;let r=new Set(Le.flatMap(a=>a.items)),i=o.sections.map(a=>a.id).filter(a=>a!=="core"&&!r.has(a));o.nav=Le.map((a,u)=>({label:a.label,items:uq[m]||O(m)):[...i,...a.items]}));for(let a of o.sections)for(let u of a.fields)o.fields.set(u.path.join("."),u);o.config=t.config,o.version=t.version,o.exists=t.exists,o.path=t.path,o.parseError=t.parse_error,o.issues=t.issues;let s=t.exists&&!t.parse_error;o.saved=s?L():"",o.validated=s?L():null}),ze({focus:!1}),history.replaceState(null,"",pt(d.page))}var gn=null;async function vn(){let e=L();if(!(!d.schema||d.validated===e||gn===e)&&!Object.values(d.toolParams).some(t=>t.error)){gn=e;try{let t=await _t("POST","/api/validate",{config:JSON.parse(e)});if(!t.ok)return;b(o=>{o.issues=t.data.issues,o.validated=e})}catch{}}}async function He(){if(!(d.busy||!d.schema)){if(d.exists&&!W()&&!d.parseError)return G("success","No changes to save.");if(_o()){b(e=>{e.busy="save"});try{let e=await _t("POST","/api/save",{config:d.config,version:d.version});if(e.data.issues&&(d.issues=e.data.issues,d.validated=L()),e.ok){d.version=e.data.version,d.config=e.data.config,d.toolParams={},d.validated=L(),d.saved=L(),d.exists=!0,d.parseError=null;let t=e.data.path.split(/[\\/]/).pop();G("success",e.data.backup?`Saved ${t}. The previous version is in ${t}.bak.`:`Created ${t}.`)}else if(e.status===409)G("error",e.data.error,{label:"Reload",run:()=>location.reload()});else if(e.status===422){let{errors:t}=_e();G("error",`Not saved: ${fe(t,"error")} to fix first.`,{label:"Show",run:go})}else G("error",e.data.error||"Save failed.")}catch{G("error","Cannot reach the config server.")}finally{b(e=>{e.busy=null})}}}}function _o(){let e=Object.values(d.toolParams).some(t=>t.error);return e&&(D("remote_tools"),G("error","A remote tool has invalid parameters JSON.")),!e}function yn(e,t){b(o=>{t?o.config[e.key]=un(e.default):delete o.config[e.key],e.key==="remote_tools"&&(o.toolParams={})})}function go(){let e=d.issues.find(t=>t.level==="error")||d.issues[0];e&&D(e.section,De(e.path))}var bo=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth";function xo(e){return e==="core"?"get-running":q[e]||O(e)?e:"get-running"}function ze({focus:e=!0}={}){b(t=>{t.page=xo(ln()),t.query="",t.drawerOpen=!1}),e&&requestAnimationFrame(()=>{window.scrollTo(0,0),document.getElementById("page-title")?.focus({preventScroll:!0})})}function D(e,t){let o=pt(e);location.hash!==o&&history.pushState(null,"",o),ze(),t&&requestAnimationFrame(()=>requestAnimationFrame(()=>{let r=document.getElementById(t);r&&(r.scrollIntoView({block:"center",behavior:bo()}),r.focus({preventScroll:!0}))}))}function Fe(e){b(t=>{t.query=e})}var vo=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches,bn;function se(e,{instant:t=!1}={}){try{localStorage.setItem("tg-sidebar",e?"shown":"hidden")}catch{}clearTimeout(bn);let o=t||vo();b(r=>{r.sideOpen=e,e?(r.sideRender=!0,r.sideShown=o):r.sideShown=!1,!e&&o&&(r.sideRender=!1)}),e&&!o&&requestAnimationFrame(()=>requestAnimationFrame(()=>b(r=>{r.sideShown=!0}))),!e&&!o&&(bn=setTimeout(()=>b(r=>{r.sideOpen||(r.sideRender=!1)}),200))}function gt(){d.sideOpen||se(!0,{instant:!0}),requestAnimationFrame(()=>{let e=document.getElementById("q");e?.focus(),e?.select()})}function kn(e){b(t=>{e&&(t.tab=e),t.drawerOpen=!0})}var Ue=()=>b(e=>{e.drawerOpen=!1});async function wn(){let e="10xgraph dev";try{await navigator.clipboard.writeText(e)}catch{try{let t=document.createElement("textarea");t.value=e,document.body.append(t),t.select(),document.execCommand("copy"),t.remove()}catch{}}b(t=>{t.copied=!0}),setTimeout(()=>b(t=>{t.copied=!1}),2e3)}async function Cn(){try{await navigator.clipboard.writeText(JSON.stringify(d.config,null,2)+`
+`),G("success","Copied 10xgraph.json to the clipboard.")}catch{G("error","The clipboard is not available here.")}}var yo=0;function G(e,t,o){let r=++yo;b(i=>{i.toasts=[...i.toasts,{id:r,kind:e,message:t,action:o}]}),setTimeout(()=>qe(r),o?8e3:4e3)}function qe(e){b(t=>{t.toasts=t.toasts.filter(o=>o.id!==e)})}var Sn={box:'',key:'',shield:'',gauge:'',arrows:'',activity:'',layers:'',wrench:'',flask:'',target:'',checkCircle:'',xCircle:'',alert:'',info:'',file:'',copy:'',x:'',plus:'',trash:'',panel:'',chevronDown:'',expand:'',collapse:'',power:'',spinner:'',book:'',flag:'',lock:'',play:'',bolt:'',terminal:'',search:'',chevronRight:'',code:'',circle:'',external:''},ee={"get-running":"flag",environment:"terminal",core:"flag",auth:"lock",cache_store:"gauge",observability:"activity",metrics:"gauge",runs:"play",websocket:"bolt",ag_ui:"external",remote_tools:"wrench",media:"file",checkpointer:"layers",id_generator:"key",thread_name_generator:"file",store:"box",injectq:"arrows",test:"flask",evaluation:"target"};var ko=0;function n(e,t,o,r,i,s){t||(t={});var a,u,m=t;if("ref"in m)for(u in m={},t)u=="ref"?a=t[u]:m[u]=t[u];var l={type:e,props:m,key:o,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--ko,__i:-1,__u:0,__source:i,__self:s};if(typeof e=="function"&&(a=e.defaultProps))for(u in a)m[u]===void 0&&(m[u]=a[u]);return v.vnode&&v.vnode(l),l}function _({name:e,class:t="size-4"}){return/(^|\s)size-/.test(t)||(t=`size-4 ${t}`),n("span",{class:`inline-flex shrink-0 ${t}`,"aria-hidden":"true",children:n("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"size-full",dangerouslySetInnerHTML:{__html:Sn[e]}})})}function ie({class:e,children:t}){return n("span",{class:`inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-semibold ${e}`,children:t})}function be({id:e,on:t,label:o,onChange:r}){return n("button",{id:e,type:"button",role:"switch","aria-checked":t?"true":"false","aria-label":o,class:`focus-ring relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 before:absolute before:-inset-2.5 before:content-[''] ${t?"bg-brand-solid":"bg-line-strong"}`,onClick:()=>r(!t),children:n("span",{class:`pointer-events-none inline-block size-5 rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-200 ease-out ${t?"translate-x-[18px]":"translate-x-0.5"}`})})}function te({id:e,label:t,options:o,value:r,onPick:i}){return n("div",{id:e,role:"radiogroup","aria-label":t,class:"inline-flex max-w-full flex-wrap gap-0.5 rounded-[10px] border border-line-strong bg-raised p-0.5",children:o.map(([s,a])=>n("button",{type:"button",role:"radio","aria-checked":s===r?"true":"false",class:`focus-ring min-h-10 cursor-pointer rounded-lg px-3.5 text-sm font-medium transition-colors duration-150 ${s===r?"bg-surface text-ink shadow-card":"text-muted hover:text-ink"}`,onClick:()=>{s!==r&&i(s)},children:a},s))})}function A({id:e,options:t,value:o,invalid:r,onChange:i,plain:s}){return n("div",{class:"relative",children:[n("select",{id:e,value:o,class:`input cursor-pointer appearance-none pr-9 ${s?"":"font-mono"} ${r?"input-invalid":""}`,onChange:a=>i(a.currentTarget.value),children:t.map(([a,u])=>n("option",{value:a,children:u},a))}),n(_,{name:"chevronDown",class:"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-faint"})]})}function H({id:e,values:t,placeholder:o,suggestions:r,onChange:i}){let s=Me(null),a=m=>{let l=m.trim();s.current&&(s.current.value=""),l&&!t.includes(l)&&i([...t,l])},u=r?`${e}-options`:void 0;return n("div",{class:"flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-line-strong bg-sunken px-1.5 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-ring sm:py-1",onClick:m=>{m.target===m.currentTarget&&s.current?.focus()},children:[t.map((m,l)=>n("span",{class:"inline-flex h-7 items-center gap-0.5 rounded-md border border-line bg-raised pr-0.5 pl-2 font-mono text-xs text-body sm:h-6",children:[m,n("button",{type:"button","aria-label":`Remove ${m}`,class:"inline-flex size-6 cursor-pointer items-center justify-center rounded text-faint transition-colors hover:bg-hover hover:text-ink sm:size-5",onClick:()=>i(t.filter((p,h)=>h!==l)),children:n(_,{name:"x",class:"size-3"})})]},m)),n("input",{ref:s,id:e,type:"text",list:u,placeholder:t.length?"Add...":o,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:"h-7 min-w-[7rem] flex-1 bg-transparent px-1 font-mono text-base text-ink outline-none placeholder:text-faint sm:text-sm",onKeyDown:m=>{let l=m.currentTarget;(m.key==="Enter"||m.key===",")&&l.value.trim()?(m.preventDefault(),a(l.value)):m.key==="Backspace"&&!l.value&&t.length&&i(t.slice(0,-1))},onChange:m=>{r?.includes(m.currentTarget.value.trim())&&a(m.currentTarget.value)},onBlur:m=>a(m.currentTarget.value)}),r?n("datalist",{id:u,children:r.filter(m=>!t.includes(m)).map(m=>n("option",{value:m},m))}):null]})}function xe({label:e,path:t,help:o,id:r,required:i}){return n("div",{class:"min-w-0",children:[n("label",{for:r,class:"block text-sm font-medium text-ink",children:[e,i?n("span",{class:"text-danger",children:" *"}):null]}),n("div",{class:"mt-0.5 font-mono text-xs text-faint",children:t}),o?n("p",{class:"mt-1.5 text-[13px] leading-5 text-muted",children:o}):null]})}function P({issue:e}){if(!e)return null;let t=e.level==="error";return n("p",{class:`mt-2 flex gap-1.5 text-[13px] leading-5 ${t?"text-danger":"text-warn"}`,role:t?"alert":void 0,children:[n(_,{name:t?"xCircle":"alert",class:"mt-0.5 size-3.5"}),n("span",{children:e.message})]})}function bt({children:e}){return n("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-8",children:e})}function Y({id:e,label:t,path:o,value:r,placeholder:i,onInput:s,issue:a,help:u}){return n(bt,{children:[n(xe,{label:t,path:o,help:u,id:e}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n("input",{id:e,type:"text",value:r??"",placeholder:i,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${a?.level==="error"?"input-invalid":""}`,onInput:m=>s(m.currentTarget.value)}),n(P,{issue:a})]})]})}function xt({name:e,label:t,options:o,value:r,onPick:i}){return n("div",{role:"radiogroup","aria-label":t,class:"grid gap-3 sm:grid-cols-2",children:o.map(([s,a,u])=>{let m=s===r;return n("button",{type:"button",role:"radio","aria-checked":m?"true":"false",id:`${e}-${s}`,class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 ${m?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{m||i(s)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${m?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:m?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:a}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:u})]})]},s)})})}function R({title:e,children:t}){return n("section",{children:[e?n("h4",{class:"eyebrow mb-2.5",children:e}):null,t]})}var wo={info:["info","border-brand/20 bg-brand-soft","text-brand"],warn:["alert","border-warn/25 bg-warn-soft","text-warn"],error:["xCircle","border-danger/25 bg-danger-soft","text-danger"]};function ve({kind:e,title:t,lines:o}){let[r,i,s]=wo[e];return n("div",{class:`flex gap-3 rounded-xl border px-4 py-3 ${i}`,role:e==="error"?"alert":void 0,children:[n(_,{name:r,class:`mt-0.5 size-4 ${s}`}),n("div",{class:"min-w-0 space-y-1 text-[13px] leading-5 text-body",children:[t?n("p",{class:"font-medium text-ink",children:t}):null,o.map((a,u)=>n("p",{children:a},u))]})]})}function vt({issue:e}){return n(ve,{kind:e.level==="error"?"error":"warn",lines:[n("span",{children:[e.path?n("code",{class:"mr-1.5 font-mono text-xs text-muted",children:e.path}):null,e.message]})]})}function Tn({icon:e,tint:t,title:o,text:r,children:i}){return n("div",{class:"px-6 py-14 text-center",children:[n("div",{class:`mx-auto flex size-11 items-center justify-center rounded-full bg-raised ${t}`,children:n(_,{name:e,class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:o}),n("p",{class:"mx-auto mt-1 max-w-xs text-sm leading-6 text-muted",children:r}),i]})}function We({title:e,open:t,class:o="",children:r}){return n("details",{class:`group ${o}`,open:t,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform duration-150 group-open:rotate-90"}),e]}),r]})}function $n(){let e=Me(null);U(()=>{let a=e.current;a&&(d.drawerOpen&&!a.open?a.showModal():!d.drawerOpen&&a.open&&a.close())});let t=d.issues.length>0,o=t&&d.tab==="problems"?"problems":"json",{errors:r,warnings:i}=_e(),s=(a,u,m)=>n("button",{type:"button",role:"tab",id:`tab-${a}`,"aria-selected":o===a?"true":"false",class:`focus-ring inline-flex h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-150 ${o===a?"bg-raised text-ink":"text-muted hover:text-ink"}`,onClick:()=>b(l=>{l.tab=a}),children:[u,m]});return n("dialog",{ref:e,"aria-label":"10xgraph.json preview",class:"m-0 ml-auto h-dvh max-h-none w-[min(540px,100vw)] border-0 border-l border-line-strong bg-surface p-0 text-body shadow-pop backdrop:bg-black/45",onClose:()=>{d.drawerOpen&&Ue()},onClick:a=>{a.target===a.currentTarget&&Ue()},children:n("div",{class:"flex h-full flex-col",children:[n("div",{class:"flex h-14 shrink-0 items-center gap-1 border-b border-line px-3",children:[n("div",{role:"tablist",class:"flex flex-1 gap-1",children:[s("json","10xgraph.json",null),t?s("problems","Problems",n("span",{class:`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${r?"bg-danger-soft text-danger":"bg-warn-soft text-warn"}`,children:r+i})):null]}),n("button",{type:"button",class:"icon-btn","aria-label":"Close preview",onClick:Ue,children:n(_,{name:"x",class:"size-5"})})]}),n("div",{class:"min-h-0 flex-1 overflow-y-auto overscroll-contain",children:o==="problems"?n(Co,{}):n(To,{})})]})})}function Co(){let{errors:e,warnings:t}=_e(),o=d.sections.map(r=>[r,re(r.id)]).filter(([,r])=>r.length);return n("div",{children:[n("div",{class:"border-b border-line px-4 py-3",children:n("p",{class:"text-sm text-muted",children:[n("span",{class:e?"font-medium text-danger":"",children:fe(e,"error")})," \xB7 ",n("span",{class:t?"font-medium text-warn":"",children:fe(t,"warning")})]})}),n("div",{class:"space-y-5 px-3 py-4",children:o.map(([r,i])=>n("div",{children:[n("p",{class:"eyebrow mb-1.5 flex items-center gap-2 px-2",children:[n(_,{name:ee[r.id]||"box",class:"size-3.5"}),r.title]}),n("ul",{class:"space-y-0.5",children:i.map((s,a)=>{let u=s.level==="error";return n("li",{children:n("button",{type:"button",onClick:()=>D(r.id,De(s.path)),class:"focus-ring flex min-h-11 w-full cursor-pointer gap-2.5 rounded-lg px-2 py-2 text-left transition-colors duration-150 hover:bg-raised",children:[n(_,{name:u?"xCircle":"alert",class:`mt-0.5 size-4 ${u?"text-danger":"text-warn"}`}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-[13px] leading-5 text-body",children:s.message}),s.path?n("span",{class:"mt-0.5 block truncate font-mono text-xs text-faint",children:s.path}):null]})]})},a)})})]},r.id))})]})}function So(e){return e.replace(/&/g,"&").replace(//g,">").replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,o=>{let r="var(--af-json-number)";return o.startsWith('"')?r=o.endsWith(":")?"var(--af-json-key)":"var(--af-json-string)":(o==="true"||o==="false"||o==="null")&&(r="var(--af-json-literal)"),`${o}`})}function To(){let e=JSON.stringify(d.config,null,2).split(`
+`);return n("div",{children:[n("div",{class:"sticky top-0 flex items-center justify-between gap-3 border-b border-line bg-surface px-4 py-2.5",children:[n("p",{class:"text-xs text-faint",children:W()||!d.exists?"Preview of what Save will write":"Matches the file on disk"}),n("button",{type:"button",class:"btn btn-ghost h-8 px-2.5 text-xs",onClick:Cn,children:[n(_,{name:"copy",class:"size-3.5"}),"Copy"]})]}),n("div",{class:"py-3 pr-4 font-mono text-[12.5px] leading-5",children:e.map((t,o)=>n("div",{class:"flex",children:[n("span",{class:"w-10 shrink-0 pr-3 text-right text-faint/70 select-none",children:o+1}),n("span",{class:"min-w-0 flex-1 break-all whitespace-pre-wrap",dangerouslySetInnerHTML:{__html:So(t)}})]},o))})]})}function kt(e){let t=[];for(let o of e){let r=o.group||"",i=t.find(s=>s.name===r);i||t.push(i={name:r,fields:[]}),i.fields.push(o)}return t}var B=(e,t)=>b(o=>{if(t===void 0){he(o.config,e);return}let r=e.length>1?o.sections.find(i=>i.optional&&i.key===e[0]):null;r&&(o.config[r.key]===void 0||o.config[r.key]===null)&&(o.config[r.key]=JSON.parse(JSON.stringify(r.default))),oe(o.config,e,t)}),$o=e=>`f-${e.path.join("-")}`;function Eo({id:e,value:t,field:o,invalid:r,disabled:i}){let[s,a]=K(String(t??""));return U(()=>{(s===""?t!==void 0:Number(s)!==t)&&a(String(t??""))},[t]),n("input",{id:e,type:"number",inputmode:"decimal",value:s,disabled:i,min:o.min,step:o.step??"any",placeholder:o.placeholder??o.default??"",class:`input max-w-40 font-mono tabular-nums ${r?"input-invalid":""}`,onInput:u=>{let m=u.currentTarget.value;a(m),m===""?B(o.path,void 0):Number.isNaN(Number(m))||B(o.path,Number(m))}})}function yt({field:e,value:t}){return e.exclusive_min===void 0||typeof t!="number"||t>e.exclusive_min?null:n("p",{class:"mt-1.5 text-[13px] text-warn",children:["Must be greater than ",e.exclusive_min,"."]})}function Po({id:e,field:t,value:o,invalid:r}){let i=t.unlimited_value!==void 0&&o===t.unlimited_value,s=n(Eo,{id:e,value:i?void 0:o,field:t,invalid:r,disabled:i});if(t.null_label!==void 0){let a=o===null?"n":o===void 0?"d":"s",u=typeof t.default=="number"?t.default:300;return n("div",{children:[n(te,{id:e,label:t.label,value:a,options:[["d","Default"],["s","Seconds"],["n",t.null_label]],onPick:m=>B(t.path,m==="d"?void 0:m==="n"?null:u)}),a==="s"?n("div",{class:"mt-2.5",children:[s,n(yt,{field:t,value:o})]}):null]})}return t.unlimited_value!==void 0?n("div",{children:[n("div",{class:"flex flex-wrap items-center gap-x-4",children:[s,n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm text-body",children:[n("input",{type:"checkbox",class:"size-[18px] accent-brand-solid",checked:i,onChange:a=>B(t.path,a.currentTarget.checked?t.unlimited_value:void 0)}),"Unlimited"]})]}),n(yt,{field:t,value:o})]}):n("div",{children:[s,n(yt,{field:t,value:o})]})}function Oo({id:e,field:t,value:o}){if(t.unset_label!==void 0){let r=[["d","Default"]];return t.allow_true!==!1&&r.push(["t","On"]),r.push(["f","Off"]),n("div",{children:[n(te,{id:e,label:t.label,value:o===void 0?"d":o?"t":"f",options:r,onPick:i=>B(t.path,i==="d"?void 0:i==="t")}),n("p",{class:"mt-1.5 text-xs text-faint",children:["Default: ",t.unset_label]})]})}return n("label",{class:"inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:e,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:(o??t.default??!1)===!0,onChange:r=>B(t.path,r.currentTarget.checked)}),t.label]})}function Io({id:e,field:t,value:o,invalid:r}){if(t.type==="bool")return n(Oo,{id:e,field:t,value:o});if(t.type==="number")return n(Po,{id:e,field:t,value:o,invalid:r});if(t.type==="list")return n(H,{id:e,values:Array.isArray(o)?o:[],placeholder:t.placeholder,onChange:i=>B(t.path,i.length?i:void 0)});if(t.type==="select"){let i=t.options.map(s=>Array.isArray(s)?s:[s,s]);return t.unset_label!==void 0&&i.unshift(["",t.unset_label]),n(A,{id:e,options:i,invalid:r,value:o??(t.unset_label!==void 0?"":t.default??(Array.isArray(t.options[0])?t.options[0][0]:t.options[0])),onChange:s=>B(t.path,s===""?void 0:s)})}return n("input",{id:e,type:"text",value:o??"",placeholder:t.placeholder,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${r?"input-invalid":""}`,onInput:i=>B(t.path,i.currentTarget.value===""?void 0:i.currentTarget.value)})}function I({field:e}){let t=e.path.join("."),o=$o(e),r=N(d.config,e.path),i=S(t),s=ft(e),a=fn(e),u=e.type==="bool"&&e.unset_label===void 0,m=e.default!==void 0&&!s,l=Array.isArray(e.default)?e.default.join(", "):String(e.default??"");return n("div",{id:`row-${o}`,class:`relative pt-5 pb-1 ${s?"before:absolute before:top-6 before:-left-2.5 before:bottom-2 before:w-[3px] before:rounded before:bg-brand before:content-[''] sm:before:-left-3.5":""}`,children:[n("div",{class:"flex min-h-6 items-center gap-2.5",children:[u?null:n("label",{for:o,class:`text-sm font-semibold text-ink ${a?"opacity-50":""}`,children:[e.label,e.required?n("span",{class:"text-danger",children:" *"}):null]}),u?null:n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:t}),m&&!u?n("span",{class:"rounded border border-line px-1.5 text-[11px] leading-4 font-medium text-faint",children:"default"}):null,n("span",{class:"flex-1"}),s?n("button",{type:"button",class:"focus-ring min-h-8 cursor-pointer rounded px-1 text-[13px] font-medium text-brand hover:underline",title:`Reset to default${e.default!==void 0?` (${l})`:""}`,"aria-label":`Reset ${e.label} to default`,onClick:()=>b(p=>he(p.config,e.path)),children:"Reset"}):null]}),e.help?n("p",{class:`mt-0.5 max-w-prose text-[13px] leading-5 text-muted ${a?"opacity-50":""}`,children:e.help}):null,n("fieldset",{disabled:a,class:`m-0 mt-2.5 min-w-0 max-w-[520px] border-0 p-0 ${a?"opacity-50":""}`,children:n(Io,{id:o,field:e,value:r,invalid:i?.level==="error"})}),a?n("p",{class:"mt-1.5 text-xs text-faint",children:hn(e)}):null,n(P,{issue:i})]})}var Ao=[["","Choose reach"],["own","own"],["tenant","tenant"],["all","all"]],On=e=>Array.isArray(e)?e.length>0:g(e)&&Object.keys(e).length>0;function In(){let e=d.config.auth;return e==="jwt"?{method:"jwt"}:g(e)?e:{method:"none"}}function z(e){b(t=>{let o={...In()};e(o),t.config.auth=o.method==="none"?null:o})}function Ve(e,t,o){o==null||o===""||typeof o!="string"&&!On(o)?delete e[t]:e[t]=o}function Ro(e){return e.roles_claim!==void 0||On(e.roles)?"roles":e.permissions_claim!==void 0?"permissions":"default"}var jo=e=>d.issues.filter(t=>t.path===e||t.path.startsWith(`${e}.`));function wt({prefix:e}){return jo(e).map((t,o)=>n(P,{issue:t},o))}function An({id:e,label:t,name:o,onRename:r}){let[i,s]=K(o);return U(()=>s(o),[o]),n("div",{children:[n("label",{for:e,class:"sr-only",children:t}),n("input",{id:e,type:"text",value:i,class:"input font-mono",autocomplete:"off",spellcheck:!1,onInput:a=>s(a.currentTarget.value),onChange:a=>{r(a.currentTarget.value.trim())||s(o)}})]})}var Be=({children:e})=>n("span",{class:"mb-1.5 block text-xs font-medium text-faint",children:e});function Rn(e,t,o){return!o||o!==t&&o in e?null:Object.fromEntries(Object.entries(e).map(([r,i])=>[r===t?o:r,i]))}function jn({sec:e}){let t=In(),o=t.method??"none",r=Ro(t);return n("div",{class:"space-y-6",children:[n(R,{title:"Method",children:n(xt,{name:"auth-method",label:"Authentication method",value:o,options:[["none","None","Every request is accepted. For local development."],["jwt","JWT","Verify bearer tokens signed with JWT_SECRET_KEY."],["custom","Custom","Your own BaseAuth subclass decides who is calling."]],onPick:i=>z(s=>{s.method=i,i==="custom"?s.path=s.path??"":delete s.path})})}),o==="custom"?n(R,{title:"Class",children:n("div",{class:"panel",children:n(Y,{id:"auth-path",label:"Auth class",path:"auth.path",value:t.path,placeholder:"auth.custom:MyAuth",help:"Import path of a BaseAuth subclass, as module:attribute.",issue:S("auth.path"),onInput:i=>z(s=>{s.path=i})})})}):null,o!=="none"?n(x,{children:[n(R,{title:"Permission source",children:n(xt,{name:"auth-source",label:"Permission source",value:r,options:[["default","Default","Every signed-in user, own data only."],["roles","Roles from a claim","Map the roles in the token to permissions."],["permissions","Permissions from a claim","The token already lists the permissions."]],onPick:i=>z(s=>{delete s.roles_claim,delete s.permissions_claim,delete s.roles,delete s.default_role,i==="roles"&&(s.roles_claim="roles",s.roles={member:{permissions:[],reach:"own"}}),i==="permissions"&&(s.permissions_claim="permissions")})})}),r==="roles"?n(Lo,{auth:t,registry:e.registry}):null,r==="permissions"?n(R,{title:"Claim",children:n("div",{class:"panel",children:n(Y,{id:"auth-permissions-claim",label:"Permissions claim",path:"auth.permissions_claim",value:t.permissions_claim,placeholder:"permissions",issue:S("auth.permissions_claim"),help:"Token claim holding a list of permissions, such as graph:invoke.",onInput:i=>z(s=>{s.permissions_claim=i})})})}):null,n(R,{title:"Tenants",children:n("div",{class:"panel divide-y divide-line",children:[n("div",{class:"px-5 py-4",children:[n(xe,{label:"Tenant claims",path:"auth.tenant_claims",id:"auth-tenant-claims",help:"Token claims that name the tenant, such as org_id. Rows are separated by them."}),n("div",{class:"mt-3",children:n(H,{id:"auth-tenant-claims",values:t.tenant_claims??[],placeholder:"org_id",onChange:i=>z(s=>Ve(s,"tenant_claims",i))})}),n(wt,{prefix:"auth.tenant_claims"})]}),r==="roles"?n(Mo,{auth:t}):null]})}),n(No,{auth:t})]}):null]})}function Mo({auth:e}){let t=Object.keys(g(e.roles)?e.roles:{});return n(bt,{children:[n(xe,{label:"Default role",path:"auth.default_role",id:"auth-default-role",help:"Given to a signed-in user whose token carries no role."}),n("div",{class:"min-w-0 sm:pt-0.5",children:[n(A,{id:"auth-default-role",value:e.default_role??"",options:[["","None"],...t.map(o=>[o,o])],onChange:o=>z(r=>Ve(r,"default_role",o))}),n(P,{issue:S("auth.default_role")})]})]})}function Lo({auth:e,registry:t}){let o=g(e.roles)?e.roles:{},r=Object.entries(o),i=Object.keys(o),s=["*",...Object.entries(t||{}).flatMap(([p,h])=>[`${p}:*`,...h.map(c=>`${p}:${c}`)])],a=p=>z(h=>{h.roles=p}),u=(p,h)=>a({...o,[p]:{...o[p],...h}}),m=()=>{let p=r.length+1;for(;`role${p}`in o;)p++;a({...o,[`role${p}`]:{permissions:[],reach:"own"}})},l=(p,h)=>{let c=Rn(o,p,h);return c?(z(f=>{f.roles=Object.fromEntries(Object.entries(c).map(([y,T])=>[y,T.inherits?{...T,inherits:T.inherits.map(w=>w===p?h:w)}:T])),f.default_role===p&&(f.default_role=h),g(f.system_identities)&&(f.system_identities=Object.fromEntries(Object.entries(f.system_identities).map(([y,T])=>[y,{...T,roles:(T.roles??[]).map(w=>w===p?h:w)}])))}),!0):!1};return n(R,{title:"Roles",children:n("div",{class:"panel",children:[n("div",{class:"border-b border-line",children:n(Y,{id:"auth-roles-claim",label:"Roles claim",path:"auth.roles_claim",value:e.roles_claim,placeholder:"roles",issue:S("auth.roles_claim"),help:"Token claim holding the user's list of role names.",onInput:p=>z(h=>{h.roles_claim=p})})}),r.length?n("div",{class:"divide-y divide-line",children:r.map(([p,h],c)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_8rem_auto] items-start gap-3",children:[n(An,{id:`role-name-${c}`,label:"Role name",name:p,onRename:f=>l(p,f)}),n(A,{id:`role-reach-${c}`,value:h.reach??"",options:Ao,invalid:S(`auth.roles.${p}.reach`)?.level==="error",onChange:f=>u(p,{reach:f})}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove role ${p}`,onClick:()=>a(Object.fromEntries(r.filter(([f])=>f!==p))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Be,{children:"Permissions"}),n(H,{id:`role-permissions-${c}`,values:h.permissions??[],placeholder:"graph:invoke, threads:*, *",suggestions:s,onChange:f=>u(p,{permissions:f})})]}),n("div",{children:[n(Be,{children:"Inherits"}),n(H,{id:`role-inherits-${c}`,values:h.inherits??[],placeholder:"another role",suggestions:i.filter(f=>f!==p),onChange:f=>{let y={...h};Ve(y,"inherits",f),a({...o,[p]:y})}})]}),n(wt,{prefix:`auth.roles.${p}`})]},c))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"No roles yet. Add one to use roles."}),n("div",{class:"flex items-center justify-between gap-4 border-t border-line px-5 py-3",children:[n("p",{class:"text-[13px] text-muted",children:["Reach: ",n("code",{class:"font-mono",children:"own"})," the user's rows, ",n("code",{class:"font-mono",children:"tenant"})," every row of their tenant, ",n("code",{class:"font-mono",children:"all"})," every row."]}),n("button",{type:"button",class:"btn btn-ghost",onClick:m,children:[n(_,{name:"plus"}),"Add role"]})]})]})})}function No({auth:e}){let t=g(e.system_identities)?e.system_identities:{},o=Object.entries(t),r=Object.keys(g(e.roles)?e.roles:{}),i=l=>z(p=>Ve(p,"system_identities",l)),s=(l,p)=>i({...t,[l]:{...t[l],...p}}),a=()=>{let l=o.length+1;for(;`job${l}`in t;)l++;i({...t,[`job${l}`]:{roles:[],tenant:{}}})},u=l=>Object.entries(l??{}).map(([p,h])=>`${p}=${h}`),m=l=>Object.fromEntries(l.filter(p=>p.includes("=")).map(p=>[p.slice(0,p.indexOf("=")).trim(),p.slice(p.indexOf("=")+1).trim()]));return n(R,{title:"System identities",children:n("div",{class:"panel",children:[o.length?n("div",{class:"divide-y divide-line",children:o.map(([l,p],h)=>n("div",{class:"space-y-3 px-5 py-4",children:[n("div",{class:"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3",children:[n(An,{id:`system-name-${h}`,label:"Identity name",name:l,onRename:c=>{let f=Rn(t,l,c);return f&&i(f),!!f}}),n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${l}`,onClick:()=>i(Object.fromEntries(o.filter(([c])=>c!==l))),children:n(_,{name:"trash"})})]}),n("div",{children:[n(Be,{children:"Roles"}),n(H,{id:`system-roles-${h}`,values:p.roles??[],placeholder:"member",suggestions:r,onChange:c=>s(l,{roles:c})})]}),n("div",{children:[n(Be,{children:"Tenant, as claim=value"}),n(H,{id:`system-tenant-${h}`,values:u(p.tenant),placeholder:"org_id=acme",onChange:c=>s(l,{tenant:m(c)})})]}),n(wt,{prefix:`auth.system_identities.${l}`})]},h))}):n("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"None. Scheduled jobs and other non-user callers can run as one."}),n("div",{class:"flex justify-end border-t border-line px-5 py-3",children:n("button",{type:"button",class:"btn btn-ghost",onClick:a,children:[n(_,{name:"plus"}),"Add identity"]})})]})})}var Ge=[["memory","Memory","This process only"],["redis","Redis","Shared. Redis, Valkey, Dragonfly, KeyDB"],["custom","Custom","Your BaseCacheStore class"]],Do={requests:100,window:60,by:"ip"},Je=()=>g(d.config.cache_store)?d.config.cache_store:{};function Ct(e){b(t=>{let o=g(t.config.cache_store)?t.config.cache_store:{};e(o),Object.keys(o).length?t.config.cache_store=o:delete t.config.cache_store})}var Ho=()=>{let e=Je().rate_limit;return g(e)&&e.enabled!==!1},zo=()=>{let e=Je().token_revocation;return e===!0||g(e)&&e.enabled!==!1};function Fo(e){Ct(t=>{delete t.url,delete t.path,e==="memory"?delete t.type:t.type=e})}function Uo(e){Ct(t=>{e?g(t.rate_limit)?delete t.rate_limit.enabled:t.rate_limit={...Do}:delete t.rate_limit})}function qo(e){Ct(t=>{e?g(t.token_revocation)?delete t.token_revocation.enabled:t.token_revocation=!0:delete t.token_revocation})}var En=(e,t)=>b(o=>{oe(o.config,e,t)});function Wo({value:e,onPick:t}){let o=(r,i)=>{let s=r.key==="ArrowRight"||r.key==="ArrowDown"?1:r.key==="ArrowLeft"||r.key==="ArrowUp"?-1:0;if(!s)return;r.preventDefault();let a=Ge[(i+s+Ge.length)%Ge.length][0];t(a),requestAnimationFrame(()=>document.getElementById(`cache-type-${a}`)?.focus())};return n("div",{role:"radiogroup","aria-label":"Cache store type",class:"grid gap-3 sm:grid-cols-3",children:Ge.map(([r,i,s],a)=>{let u=r===e;return n("button",{type:"button",role:"radio",id:`cache-type-${r}`,"aria-checked":u?"true":"false",tabIndex:u?0:-1,onKeyDown:m=>o(m,a),class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 motion-reduce:transition-none ${u?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{u||t(r)},children:[n("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${u?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:u?n("span",{class:"size-1.5 rounded-full bg-white"}):null}),n("span",{class:"min-w-0",children:[n("span",{class:"block text-sm font-medium text-ink",children:i}),n("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:s})]})]},r)})})}function ye({warn:e,children:t}){return n("p",{class:`mt-3 flex items-start gap-1.5 text-[13px] leading-5 ${e?"text-warn":"text-muted"}`,children:[e?n(_,{name:"alert",class:"mt-0.5 size-3.5"}):null,n("span",{children:t})]})}function Pn({id:e,title:t,text:o,on:r,onChange:i}){return n("div",{class:"flex items-center gap-4",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-base font-semibold text-ink",children:t}),n("p",{class:"mt-0.5 text-[13px] leading-5 text-muted",children:o})]}),n(be,{id:e,on:r,label:t,onChange:i})]})}var Go=[["ip","IP"],["user","User"],["global","Global"]];function Bo({field:e,memory:t}){let[o,r]=K(!1),i=Je().rate_limit,s=g(i)&&i.by||"ip",a=!(g(i)&&i.fail_open===!1),u=["trusted_proxy_headers","trusted_proxy_hops","trusted_proxies","exclude_paths"];return n(x,{children:[t?n(ye,{warn:!0,children:"Counts per worker with the Memory store."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:e("cache_store.rate_limit.requests")}),n(I,{field:e("cache_store.rate_limit.window")}),n("div",{class:"pt-5",children:[n("span",{id:"cache-by-label",class:"block text-sm font-semibold text-ink",children:"Limit by"}),n("div",{class:"mt-2.5",children:n(te,{id:"f-cache_store-rate_limit-by",label:"Limit by",options:Go,value:s,onPick:m=>En(["cache_store","rate_limit","by"],m)})}),n(P,{issue:S("cache_store.rate_limit.by")})]}),n("div",{class:"pt-5",children:[n("span",{id:"cache-fail-open-label",class:"block text-sm font-semibold text-ink",children:"Fail open"}),n("div",{class:"mt-2.5 flex items-center gap-3",children:[n(be,{id:"f-cache_store-rate_limit-fail_open",on:a,label:"Fail open",onChange:m=>En(["cache_store","rate_limit","fail_open"],m)}),n("p",{class:"m-0 text-[13px] leading-5 text-muted",children:a?"Allow requests if the store errors.":"Deny with 503 if the store errors."})]})]})]}),n("div",{class:"mt-5 border-t border-line pt-1",children:[n("button",{type:"button","aria-expanded":o?"true":"false","aria-controls":"cache-advanced",class:"focus-ring flex min-h-11 cursor-pointer items-center gap-2 rounded font-medium text-muted hover:text-ink",onClick:()=>r(!o),children:[n(_,{name:"chevronRight",class:`size-4 transition-transform duration-150 motion-reduce:transition-none ${o?"rotate-90":""}`}),"Advanced"]}),o?n("div",{id:"cache-advanced",children:u.map(m=>n(I,{field:e(`cache_store.rate_limit.${m}`)},m))}):null]})]})}var ae=[["rate","Rate limiting"],["revoke","Token revocation"]];function Mn({sec:e}){let[t,o]=K("rate"),r=Je().type||"memory",i=l=>e.fields.find(p=>p.path.join(".")===l),s={rate:Ho(),revoke:zo()},a=r==="memory",u={rate:!!S("cache_store.rate_limit"),revoke:!!S("cache_store.token_revocation")},m=(l,p)=>{let h=l.key==="ArrowRight"?1:l.key==="ArrowLeft"?-1:0,c=l.key==="Home"?0:l.key==="End"?ae.length-1:h?(p+h+ae.length)%ae.length:-1;c<0||(l.preventDefault(),o(ae[c][0]),document.getElementById(`cache-tab-${ae[c][0]}`)?.focus())};return n("div",{children:[n(R,{title:"Store type",children:[n(Wo,{value:r,onPick:Fo}),r==="redis"?n(I,{field:i("cache_store.url")}):null,r==="custom"?n(I,{field:i("cache_store.path")}):null,a?n("p",{class:"mt-3 mb-0 text-[13px] leading-5 text-muted",children:"Not shared between workers: each worker keeps its own locks, limits and revocations."}):null]}),n("div",{role:"tablist","aria-label":"Cache store features",class:"mt-8 flex gap-1 border-b border-line",children:ae.map(([l,p],h)=>{let c=t===l;return n("button",{type:"button",role:"tab",id:`cache-tab-${l}`,"aria-selected":c?"true":"false","aria-controls":`cache-panel-${l}`,tabIndex:c?0:-1,onKeyDown:f=>m(f,h),class:`focus-ring -mb-px flex min-h-11 cursor-pointer items-center gap-2 border-b-2 px-3 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none ${c?"border-brand-solid text-ink":"border-transparent text-muted hover:text-ink"}`,onClick:()=>o(l),children:[p,n(ie,{class:s[l]?"bg-brand-soft text-brand":"bg-raised text-muted",children:s[l]?"On":"Off"})]},l)})}),n("div",{role:"tabpanel",id:`cache-panel-${t}`,"aria-labelledby":`cache-tab-${t}`,class:"pt-5",children:t==="rate"?n(x,{children:[n(Pn,{id:"cache-rate-limit",title:"Rate limiting",on:s.rate,onChange:Uo,text:"Cap how many requests one caller can make."}),s.rate?n(Bo,{field:i,memory:a}):u.rate?n(ye,{warn:!0,children:"Off: one caller can exhaust the service."}):null]}):n(x,{children:[n(Pn,{id:"cache-token-revocation",title:"Token revocation",on:s.revoke,onChange:qo,text:"Reject revoked tokens before they expire (10xgraph revoke)."}),s.revoke?n(x,{children:[a?n(ye,{warn:!0,children:"10xgraph revoke cannot reach an in-memory store; pick Redis or Custom."}):null,n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(I,{field:i("cache_store.token_revocation.token_lifetime")}),n(I,{field:i("cache_store.token_revocation.user_ttl")})]}),n(ye,{children:"Fails closed: while the cache store is unreachable, authenticated requests get 503 and the server will not start."})]}):u.revoke?n(ye,{warn:!0,children:"Off: a leaked token works until it expires."}):null]})})]})}function Ln({sec:e}){let t=e.fields[0].path[0],o=e.choices.map(p=>p[0]),r=d.config[t],i=t==="thread_name_generator"?"random":"",s=r===void 0?i:typeof r=="string"&&o.includes(r)?r:"custom",a=[...t==="id_generator"?[["","Not set (the graph's own generator)"]]:[],...e.choices.map(([p,h])=>[p,t==="thread_name_generator"&&p==="random"?`${h} (default)`:h]),["custom","Custom (module:attr)"]],u=s===""?"The graph keeps the generator it was compiled with.":s==="custom"?"Your own class, as module:attribute.":e.choices.find(p=>p[0]===s)?.[2],m=p=>b(h=>{p===i?delete h.config[t]:h.config[t]=p==="custom"?"":p}),l=`f-${t}`;return n("div",{children:[n(R,{title:"Choice",children:n("div",{class:"max-w-[520px]",children:[n("label",{for:`${l}-select`,class:"sr-only",children:e.title}),n(A,{id:`${l}-select`,options:a,value:s,onChange:m}),n("p",{class:"mt-1.5 text-[13px] text-muted",children:u})]})}),s==="custom"?n(R,{title:"Class",children:n("div",{class:"panel",children:n(Y,{id:l,label:e.fields[0].label,path:t,value:typeof r=="string"?r:"",placeholder:e.fields[0].placeholder,help:e.fields[0].help,issue:S(t),onInput:p=>b(h=>{h.config[t]=p})})})}):null]})}function Nn(){let e=Array.isArray(d.config.remote_tools)?d.config.remote_tools:[],t=()=>{b(o=>{e.push({node:"",name:"",description:"",parameters:{type:"object",properties:{},required:[]}}),o.config.remote_tools=e}),requestAnimationFrame(()=>document.getElementById(`tool-${e.length-1}-name`)?.focus())};return e.length?n("div",{class:"space-y-4",children:[e.map((o,r)=>n(Vo,{tools:e,tool:o,index:r},r)),n("button",{type:"button",onClick:t,class:"focus-ring flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-line-strong text-sm font-medium text-muted transition-colors duration-150 hover:border-brand hover:text-brand",children:[n(_,{name:"plus"}),"Add remote tool"]})]}):n("div",{class:"rounded-xl border border-dashed border-line-strong bg-surface px-6 py-10 text-center",children:[n("div",{class:"mx-auto flex size-11 items-center justify-center rounded-full bg-raised text-faint",children:n(_,{name:"wrench",class:"size-5"})}),n("p",{class:"mt-4 text-sm font-semibold text-ink",children:"No remote tools yet"}),n("p",{class:"mx-auto mt-1 max-w-sm text-sm leading-6 text-muted",children:"Declare a tool here and the client that calls the API runs it, for example to read the user's location."}),n("button",{type:"button",class:"btn btn-outline mt-5",onClick:t,children:[n(_,{name:"plus"}),"Add remote tool"]})]})}function Vo({tools:e,tool:t,index:o}){let r=d.toolParams[o]??{text:JSON.stringify(t.parameters??{},null,2),error:null},i=a=>b(()=>{e[o]={...e[o],...a}}),s=a=>`remote_tools[${o}].${a}`;return n("section",{class:"panel",children:[n("div",{class:"flex items-center gap-3 border-b border-line px-5 py-3",children:[n(_,{name:"wrench",class:"size-4 text-faint"}),n("span",{class:"min-w-0 flex-1 truncate font-mono text-sm font-medium text-ink",children:t.name||`tool_${o+1}`}),t.node?n("span",{class:"rounded-md border border-line bg-raised px-1.5 py-0.5 font-mono text-xs text-muted",children:t.node}):null,n("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${t.name||"tool"}`,onClick:()=>b(a=>{e.splice(o,1),a.toolParams={}}),children:n(_,{name:"trash"})})]}),n("div",{class:"divide-y divide-line",children:[n(Y,{id:`tool-${o}-name`,label:"Tool name",path:s("name"),value:t.name,placeholder:"get_location",onInput:a=>i({name:a})}),n(Y,{id:`tool-${o}-node`,label:"Graph node",path:s("node"),value:t.node,placeholder:"MAIN",help:"The node whose model may call this tool.",onInput:a=>i({node:a})}),n(Y,{id:`tool-${o}-description`,label:"Description",path:s("description"),value:t.description,placeholder:"Returns the user's current city",help:"What the model reads when deciding to call it.",onInput:a=>i({description:a})}),n("div",{class:"px-5 py-4",children:[n(xe,{label:"Parameters",path:s("parameters"),help:"JSON schema of the arguments. type must be object.",id:`tool-${o}-params`}),n("textarea",{id:`tool-${o}-params`,rows:7,spellcheck:!1,value:r.text,class:`input mt-3 h-auto py-2.5 font-mono text-[13px] leading-5 ${r.error?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;b(m=>{try{e[o]={...e[o],parameters:JSON.parse(u)},m.toolParams[o]={text:u,error:null}}catch(l){m.toolParams[o]={text:u,error:l.message}}})}}),n(P,{issue:r.error?{level:"error",message:r.error}:null})]})]})]})}var $t="focus-ring min-h-8 cursor-pointer rounded px-1.5 text-[13px] font-medium text-brand transition-colors duration-150 hover:underline",X="mt-2 max-w-prose text-[13px] leading-5 text-muted",V=(e,t)=>b(o=>{t===void 0||t===""?he(o.config,e):oe(o.config,e,t)});function Et({id:e,label:t,path:o,help:r,children:i}){return n("div",{class:"pt-4",children:[n("div",{class:"flex items-baseline gap-2.5",children:[n("label",{for:e,class:"text-sm font-semibold text-ink",children:t}),o?n("code",{class:"hidden font-mono text-[11px] text-faint sm:inline",children:o}):null]}),n("div",{class:"mt-2 max-w-[520px]",children:i}),r?n("p",{class:X,children:r}):null,n(P,{issue:S(o)})]})}function le({path:e,label:t,help:o,placeholder:r}){let i=e.join("."),s=`f-${e.join("-")}`,a=N(d.config,e);return n(Et,{id:s,label:t,path:i,help:o,children:n("input",{id:s,type:"text",value:a??"",placeholder:r,autocomplete:"off",spellcheck:!1,class:`input font-mono ${S(i)?.level==="error"?"input-invalid":""}`,onInput:u=>V(e,u.currentTarget.value)})})}function Jo({path:e,label:t,help:o}){let r=e.join("."),i=`f-${e.join("-")}`,s=N(d.config,e);return n(Et,{id:i,label:t,path:r,help:o,children:n("input",{id:i,type:"number",inputmode:"numeric",min:"1",step:"1",value:s??"",class:`input max-w-40 font-mono tabular-nums ${S(r)?.level==="error"?"input-invalid":""}`,onInput:a=>{let u=a.currentTarget.value;V(e,u===""?void 0:Number(u))}})})}function St({path:e,label:t,help:o,options:r,value:i,onChange:s}){let a=`f-${e.join("-")}`;return n(Et,{id:a,label:t,path:e.join("."),help:o,children:n(A,{id:a,plain:!0,options:r,value:i,onChange:s})})}function Ko({path:e,label:t,checked:o}){let r=`f-${e.join("-")}`;return n("label",{class:"mt-3 inline-flex min-h-11 cursor-pointer items-center gap-2.5 text-sm font-semibold text-ink",children:[n("input",{id:r,type:"checkbox",class:"size-[18px] accent-brand-solid",checked:o,onChange:i=>V(e,i.currentTarget.checked)}),t]})}var Ye=[{key:"logfire",name:"Logfire",label:"Logfire",text:"Pydantic Logfire.",secret:n(x,{children:["Token: set ",n("code",{class:"font-mono text-xs",children:"LOGFIRE_TOKEN"})," in .env."]})},{key:"langsmith",name:"LangSmith",label:"LangSmith",text:"LangChain's tracing service.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"LANGSMITH_API_KEY"})," in .env."]})},{key:"phoenix",name:"Phoenix",label:"Phoenix",text:"Arize Phoenix, local or hosted.",secret:n(x,{children:["API key: set ",n("code",{class:"font-mono text-xs",children:"PHOENIX_API_KEY"})," in .env. A local Phoenix does not need one."]})},{key:"otlp",name:"OTLP endpoint",label:"OTLP endpoint",text:"Any OpenTelemetry endpoint: a collector, Tempo, Jaeger.",secret:n(x,{children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]})}],Yo=[["standard","Standard"],["spans","Spans only"],["full","Full"]],Xo={standard:"Adds model name, token counts and request parameters.",spans:"Timing and structure only. No inputs or outputs.",full:"Records prompts and tool I/O. May contain PII."},Qo=[["raw","Raw"],["hash","Hashed"],["mask","Masked"]],Zo={raw:"The user id is sent as is.",hash:"A SHA-256 of the user id, keyed when TENXGRAPH_TRACE_USER_ID_KEY is set.",mask:"The middle of the user id is hidden."},Tt=()=>g(d.config.observability)?d.config.observability:{},Xe=e=>g(Tt()[e])&&Tt()[e].enabled===!0;function Dn(e){b(t=>{g(t.config.observability)||(t.config.observability={level:"standard",user_id_mode:"raw"}),t.config.observability[e]={enabled:!0}})}function er(e){b(t=>{delete t.config.observability[e],Ye.some(o=>Xe(o.key))||delete t.config.observability})}function tr(){let e=d.config.metrics,t=g(e)&&g(e.otlp)?e.otlp.endpoint:null;return!g(e)||e.enabled!==!0||e.export!=="otlp"||!t?null:t.replace(/\/+$/,"").replace(/\/v1\/metrics$/,"")}function nr(){let e=["observability","otlp","use_metrics_endpoint"],t=N(d.config,e)===!0,o=tr(),r="f-observability-otlp-use_metrics_endpoint";return n(x,{children:[o?n("div",{class:"pt-4",children:[n("div",{class:"flex items-center gap-3",children:[n(be,{id:r,on:t,label:"Use the metrics endpoint",onChange:i=>V(e,i?!0:void 0)}),n("label",{for:r,class:"cursor-pointer text-sm font-semibold text-ink",children:"Use the metrics endpoint"})]}),n("p",{class:X,children:t?n(x,{children:["Traces go to ",n("code",{class:"font-mono text-xs text-body",children:[o,"/v1/traces"]})]}):"Send traces to the same endpoint as the metrics."}),n(P,{issue:S(e.join("."))})]}):null,t&&o?null:n(le,{path:["observability","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:"Base URL of an OTLP HTTP receiver. /v1/traces is appended."}),o?null:n("p",{class:X,children:["Sharing the metrics endpoint needs Metrics set to OTLP push with an endpoint.",t?n(x,{children:[" It is on in the file. ",n("button",{type:"button",class:$t,onClick:()=>V(e,void 0),children:"Stop sharing"})]}):null]}),n(P,{issue:t&&!o?S(e.join(".")):null}),n("p",{class:X,children:"Prometheus stores metrics only. To send traces and metrics to one place, use a collector (OTel Collector, Grafana Alloy, Grafana Cloud)."})]})}function or({dest:e}){let t=r=>["observability",e.key,r];if(e.key==="logfire"){let r=t("send_to_logfire"),i=t("console");return n(x,{children:[n(le,{path:t("service_name"),label:"Service name",placeholder:"my-agent"}),n(Ko,{path:r,label:"Send to Logfire",checked:N(d.config,r)!==!1}),n(St,{path:i,label:"Print spans to console",help:"Logfire also takes console options that JSON cannot express, so only its own behavior or Off is offered.",options:[["","Logfire's own behavior"],["off","Off"]],value:N(d.config,i)===!1?"off":"",onChange:s=>V(i,s==="off"?!1:void 0)})]})}if(e.key==="otlp")return n(nr,{});let o=e.key==="phoenix";return n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(le,{path:t("project"),label:"Project",placeholder:"my-agent"}),n(le,{path:t("endpoint"),label:"Endpoint",placeholder:o?"http://localhost:6006":"https://api.smith.langchain.com/otel",help:o?"Base collector URL. /v1/traces is appended.":void 0})]})}function rr({dest:e}){return n("div",{id:`dest-${e.key}`,class:"mt-4 rounded-xl border border-line bg-surface p-5",children:[n("div",{class:"flex items-start gap-3",children:[n("div",{class:"min-w-0 flex-1",children:[n("h3",{class:"text-sm font-semibold text-ink",children:e.name}),n("p",{class:"mt-0.5 text-[13px] text-muted",children:e.text})]}),n("button",{type:"button",class:$t,"aria-label":`Remove ${e.name}`,onClick:()=>er(e.key),children:"Remove"})]}),n(or,{dest:e}),n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:e.secret})]})}function Hn(){return n("p",{class:X,children:"These are observability publishers. They get graph, node, LLM and tool spans, AI errors included. Event publishers (Kafka, Redis, RabbitMQ) are set in code and also get task events. API and worker errors go to Metrics and logs."})}function zn(){let[e,t]=K(!1),o=Ye.filter(s=>Xe(s.key)),r=Ye.filter(s=>!Xe(s.key)),i=Tt();return n("div",{children:o.length===0?n("div",{class:"max-w-[520px]",children:[n("label",{for:"trace-dest",class:"mb-2 block text-sm font-semibold text-ink",children:"Send AI traces to"}),n(A,{id:"trace-dest",value:"",plain:!0,options:[["","None"],...r.map(s=>[s.key,s.label])],onChange:s=>{s&&Dn(s)}}),n("p",{class:X,children:"Graph runs are not traced."}),n(Hn,{})]}):n(x,{children:[n("h2",{class:"eyebrow",children:"Send AI traces to"}),o.map(s=>n(rr,{dest:s},s.key)),r.length?e?n("div",{class:"mt-4 max-w-[320px]",children:[n("label",{for:"trace-add",class:"sr-only",children:"Add another destination"}),n(A,{id:"trace-add",value:"",plain:!0,options:[["","Choose a destination"],...r.map(s=>[s.key,s.label])],onChange:s=>{t(!1),s&&Dn(s)}})]}):n("button",{type:"button",class:`${$t} mt-3 inline-flex min-h-10 items-center gap-1.5`,onClick:()=>t(!0),children:[n(_,{name:"plus",class:"size-4"}),"Add another destination"]}):null,n("div",{class:"max-w-[520px]",children:n(Hn,{})}),n("h2",{class:"eyebrow mt-8",children:"Trace settings"}),n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(St,{path:["observability","level"],label:"Level",options:Yo,value:i.level??"standard",onChange:s=>V(["observability","level"],s),help:Xo[i.level??"standard"]}),n(St,{path:["observability","user_id_mode"],label:"User id in traces",options:Qo,value:i.user_id_mode??"raw",onChange:s=>V(["observability","user_id_mode"],s),help:Zo[i.user_id_mode??"raw"]})]})]})})}var sr=[["","None"],["otlp","OTLP push"],["prometheus","Prometheus scrape"]],ir={"":"Metrics are not exported.",otlp:"Recommended for production. Works with several worker processes.",prometheus:"One process only. The API serves the scrape path, the worker its own port."},ar=[50,100,250,500,1e3,2500,5e3,1e4,3e4,6e4,12e4],Ke=()=>g(d.config.metrics)?d.config.metrics:{},Fn=()=>Ke().enabled===!0&&["otlp","prometheus"].includes(Ke().export)?Ke().export:"";function lr(e){b(t=>{if(!e){delete t.config.metrics;return}let o=g(t.config.metrics)?t.config.metrics:{};o.enabled=!0,o.export=e,e==="otlp"&&!g(o.otlp)&&(o.otlp={endpoint:null}),e==="prometheus"&&!g(o.prometheus)&&(o.prometheus={path:"/metrics",worker_port:9464}),t.config.metrics=o})}function Un(){let e=Fn(),t=Ke().latency_buckets_ms,o=S("metrics.latency_buckets_ms");return n("div",{children:[n("div",{class:"max-w-[520px]",children:[n("label",{for:"f-metrics-export",class:"mb-2 block text-sm font-semibold text-ink",children:"Export metrics"}),n(A,{id:"f-metrics-export",plain:!0,options:sr,value:e,invalid:S("metrics.export")?.level==="error",onChange:lr}),n("p",{class:X,children:ir[e]}),n(P,{issue:S("metrics.export")})]}),e===""?null:n("div",{class:"mt-5 rounded-xl border border-line bg-surface px-5 pt-1 pb-5",children:[e==="otlp"?n(le,{path:["metrics","otlp","endpoint"],label:"Endpoint",placeholder:"http://collector:4318",help:n(x,{children:["/v1/metrics is appended.",n("br",{}),"Prometheus: ",n("code",{class:"font-mono text-xs",children:"http://prometheus:9090/api/v1/otlp"})," (start it with ",n("code",{class:"font-mono text-xs",children:"--web.enable-otlp-receiver"}),")."]})}):n(x,{children:n("div",{class:"grid gap-x-6 sm:grid-cols-2",children:[n(le,{path:["metrics","prometheus","path"],label:"Scrape path",placeholder:"/metrics",help:"Route on the API server that Prometheus scrapes."}),n(Jo,{path:["metrics","prometheus","worker_port"],label:"Worker port",help:"Each worker serves its own metrics on this port."})]})}),e==="otlp"?n("p",{class:"mt-5 border-t border-line pt-3 text-[13px] text-muted",children:["Auth headers: set ",n("code",{class:"font-mono text-xs",children:"OTEL_EXPORTER_OTLP_HEADERS"})," in .env."]}):null]}),e!==""?n(We,{title:"Advanced",class:"mt-4",open:!!o||t!==void 0,children:n("div",{class:"pt-4",children:[n("label",{for:"f-metrics-latency_buckets_ms",class:"text-sm font-semibold text-ink",children:"Latency buckets (ms)"}),n("div",{class:"mt-2 max-w-[520px]",children:n(H,{id:"f-metrics-latency_buckets_ms",values:(t??ar).map(String),placeholder:"250",onChange:r=>V(["metrics","latency_buckets_ms"],r.length?r.map(Number).filter(Number.isFinite):void 0)})}),n("p",{class:X,children:"Upper bounds of the latency histogram in milliseconds, ascending."}),n(P,{issue:o})]})}):null]})}var cr=e=>e.length>1?`${e.slice(0,-1).join(", ")} and ${e[e.length-1]}`:e[0];function qn(e){let t=Ye.filter(o=>Xe(o.key)).map(o=>o.name);return t.length?{text:`Level ${mt(["observability","level"])}, sending to ${cr(t)}.`}:{text:e("observability")}}function Wn(e){let t={otlp:"OTLP push",prometheus:"Prometheus scrape"}[Fn()];return t?{text:`${t}.`}:{text:e("metrics")}}var ke=({children:e,code:t})=>n("h1",{id:"page-title",tabindex:-1,class:"flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-semibold tracking-tight text-ink focus:outline-none",children:[e,t?n("code",{class:"rounded border border-line bg-raised px-1.5 font-mono text-xs font-normal text-faint",children:t}):null]}),Ot=({children:e})=>n("p",{class:"mt-1.5 max-w-[62ch] text-[15px] leading-6 text-muted",children:e}),Gn=({children:e})=>n("h2",{class:"mt-10 mb-2 eyebrow",children:e});function Bn({id:e,text:t,warn:o,compact:r}){let i=ge(e);return i?n("a",{href:`#${e}`,onClick:s=>{s.preventDefault(),D(e)},class:`focus-ring group flex items-center gap-3.5 border-t border-line py-2 last:border-b ${r?"min-h-12":"min-h-14"}`,children:[n(_,{name:ee[e]||"box",class:"size-[18px] text-faint"}),n("span",{class:`min-w-0 flex-1 ${r?"flex flex-wrap items-baseline gap-x-4":""}`,children:[n("span",{class:`block text-sm text-ink group-hover:text-brand ${r?"min-w-40 font-medium":"font-semibold"}`,children:i.title}),n("span",{class:`block text-[13px] leading-5 ${o?"text-warn":"text-muted"}`,children:t??i.description})]}),n(_,{name:"chevronRight",class:"size-4 text-faint"})]}):null}function Pt(e){return`Not set. ${O(e)?.off_summary||""}`.trim()}function dr(){let e=d.config.auth,t=e==="jwt"?"jwt":g(e)?e.method??"none":"none";if(t==="none")return{text:Pt("auth")};let o=g(e)&&g(e.roles)?Object.keys(e.roles).length:0;return{text:`${t==="jwt"?"JWT bearer tokens":`Custom backend${e.path?`: ${e.path}`:""}`}${o?`, ${o} ${o===1?"role":"roles"}`:""}.`}}function ur(){let e=g(d.config.cache_store)?d.config.cache_store:{},t=[`${e.type||"memory"} store`],o=e.rate_limit;if(g(o)&&o.enabled!==!1){let i={ip:"per IP",user:"per user",global:"across all clients"}[o.by??"ip"]||o.by;t.push(`${o.requests??100} requests per ${o.window??60} s, ${i}`)}let r=e.token_revocation;return(r===!0||g(r)&&r.enabled!==!1)&&t.push("token revocation on"),{text:`${t.join("; ")}.`}}function Vn(){let e=O("core"),t=_n(),o=e?re("core").filter(s=>!e.fields.some(a=>a.path.join(".")===s.path)):[],r=[["auth",dr()],["cache_store",ur()],["observability",qn(Pt)],["metrics",Wn(Pt)]],i=d.nav.flatMap(s=>s.items).filter(s=>s!=="get-running");return n(x,{children:[n(ke,{children:t?"You're ready to run":"One step to run your agent"}),n(Ot,{children:q["get-running"].description}),n("div",{class:`mt-4 flex items-center gap-2 font-medium ${t?"text-ok":"text-muted"}`,children:[n(_,{name:t?"checkCircle":"circle",class:"size-[18px]"}),"Required: ",t?"1":"0"," of 1 done"]}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((s,a)=>n(vt,{issue:s},a))}):null,n("div",{class:"mt-2",children:e?kt(e.fields).flatMap(s=>s.fields).map(s=>n(I,{field:s},s.path.join("."))):null}),t?n("div",{class:"mt-7 border-t border-line pt-5",children:[n("div",{class:"flex items-center gap-2.5 font-medium text-ok",children:[n(_,{name:"checkCircle",class:"size-[18px]"}),"Ready for 10xgraph dev"]}),n("div",{class:"mt-3 flex max-w-[420px] items-center gap-2 rounded-lg border border-line-strong bg-sunken py-0 pr-1 pl-3.5",children:[n("code",{class:"flex-1 font-mono text-[13px] font-medium text-ink",children:"10xgraph dev"}),d.copied?n("span",{class:"text-xs text-ok",children:"Copied"}):null,n("button",{type:"button",class:"icon-btn",onClick:wn,"aria-label":d.copied?"Copied 10xgraph dev":"Copy command 10xgraph dev",children:n(_,{name:"copy",class:"size-[18px]"})})]}),n("p",{class:"mt-3 text-muted",children:"Everything else already has working defaults."})]}):n("p",{class:"mt-4 text-xs text-faint",children:"Add the agent graph above, then run 10xgraph dev."}),n(Gn,{children:"Before production (optional)"}),n("div",{children:r.map(([s,a])=>n(Bn,{id:s,text:a.text,warn:a.warn},s))}),n(Gn,{children:"What you can configure"}),n("div",{children:i.map(s=>n(Bn,{id:s,compact:!0},s))})]})}function Qe({vars:e}){return n("dl",{class:"mb-3 grid gap-x-4 gap-y-1 sm:grid-cols-[minmax(0,260px)_minmax(0,1fr)]",children:e.map(t=>[n("dt",{class:"py-0.5 font-mono text-xs font-medium break-all text-ink",children:t.name},`${t.name}-n`),n("dd",{class:"m-0 pb-2 text-[13px] text-muted sm:py-0.5",children:t.purpose},`${t.name}-p`)])})}function pr({vars:e,open:t}){return e?.length?n(We,{title:"Set in .env",open:t,class:"mt-9 border-t border-line pt-1",children:[n("p",{class:"mb-2 text-xs text-faint",children:"Read only. The editor never reads .env, so only names are shown."}),n(Qe,{vars:e})]}):null}function Jn({sec:e}){return e.widget==="auth"?n(jn,{sec:e}):e.widget==="remote_tools"?n(Nn,{}):e.widget==="observability"?n(zn,{}):e.widget==="metrics"?n(Un,{}):e.widget==="cache_store"?n(Mn,{sec:e}):e.widget==="id_generator"||e.widget==="thread_name_generator"?n(Ln,{sec:e}):null}function mr(e){let t=new Set([...e.fields.map(i=>i.path.join(".")),...Object.keys(ut)]),o=new Set(["cache_store.rate_limit","cache_store.token_revocation"]),r=i=>i.startsWith("auth.roles.")||i.startsWith("auth.system_identities.");return re(e.id).filter(i=>!t.has(i.path)&&!(e.id==="cache_store"&&o.has(i.path))&&!(e.id==="auth"&&r(i.path)))}function fr({sec:e}){let t=Ne(e),o=e.widget&&(t||!e.optional||e.self_managed);return n(x,{children:[o?n("div",{class:"mt-6",children:n(Jn,{sec:e})}):null,n("div",{class:e.optional&&!t?"opacity-80":"",children:kt(e.fields.filter(r=>!r.managed)).map((r,i)=>n("div",{children:[r.name?n("h3",{class:`eyebrow ${i===0?"mt-6":"mt-8 border-t border-line pt-4"}`,children:r.name}):null,r.fields.map(s=>n(I,{field:s},s.path.join(".")))]},r.name))})]})}function hr({sec:e}){let t=Ne(e),o=mr(e);return n(x,{children:[n(ke,{code:e.key,children:e.title}),n(Ot,{children:e.description}),o.length?n("div",{class:"mt-4 space-y-2",children:o.map((r,i)=>n(vt,{issue:r},i))}):null,e.optional&&!e.self_managed?n("div",{class:"mt-5 flex flex-wrap items-center gap-4",children:[n(te,{label:`Settings for ${e.title}`,value:t?"c":"d",options:[["d","Use defaults"],["c","Customize"]],onPick:r=>yn(e,r==="c")}),n("p",{class:"m-0 min-w-56 flex-1 text-[13px] text-muted",children:t?`Saved in 10xgraph.json under ${e.key}.`:e.off_summary})]}):null,e.notes?.length?n("div",{class:"mt-5",children:n(ve,{kind:"info",lines:e.notes})}):null,n(fr,{sec:e}),n(pr,{vars:e.env})]})}function _r(){let e=d.schema.environment||[];return n(x,{children:[n(ke,{children:q.environment.title}),n(Ot,{children:"These variables are set in .env or your deployment environment. The editor never reads that file, so only names and purposes are shown."}),e.map(t=>n("section",{children:[n("h2",{class:"mt-8 mb-2 text-sm font-semibold text-ink",children:t.title}),t.note?n("p",{class:"mb-2 text-xs text-faint",children:t.note}):null,n(Qe,{vars:t.vars})]},t.title))]})}var Kn=e=>String(e??"").toLowerCase();function gr(e){let t=i=>e.every(s=>Kn(i).includes(s)),o=[];for(let i of d.sections){let s=t(`${i.title} ${i.description} ${i.key||""}`),a=p=>s||t(`${p.label} ${p.help||""} ${p.path.join(".")}`),u=i.fields.filter(p=>!p.managed&&a(p)),m=(i.env||[]).filter(p=>t(`${p.name} ${p.purpose}`)),l=!!i.widget&&(s||i.fields.some(p=>p.managed&&a(p)));(u.length||m.length||l)&&o.push({id:i.id,sec:i,fields:u,env:m,widget:l})}let r=(d.schema.environment||[]).map(i=>({title:i.title,vars:i.vars.filter(s=>t(`${s.name} ${s.purpose} ${i.title}`))})).filter(i=>i.vars.length);return{results:o,envGroups:r}}function br(){let e=Kn(d.query).split(/\s+/).filter(Boolean),{results:t,envGroups:o}=gr(e),r=t.reduce((i,s)=>i+s.fields.length+s.env.length+(s.widget?1:0),0)+o.reduce((i,s)=>i+s.vars.length,0);return r?n(x,{children:[n(ke,{code:d.query,children:[r," ",r===1?"setting matches":"settings match"]}),t.map(({id:i,sec:s,fields:a,env:u,widget:m})=>n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:ee[i]||"box",class:"size-4"}),s.title,n("a",{href:`#${i}`,onClick:l=>{l.preventDefault(),D(i)},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),m?n("div",{class:"mt-4",children:n(Jn,{sec:s})}):null,a.map(l=>n(I,{field:l},l.path.join("."))),u.length?n("details",{class:"group mt-5",open:!0,children:[n("summary",{class:"focus-ring flex min-h-11 cursor-pointer list-none items-center gap-2 font-medium text-muted [&::-webkit-details-marker]:hidden",children:[n(_,{name:"chevronRight",class:"size-4 transition-transform group-open:rotate-90"}),"Set in .env"]}),n(Qe,{vars:u})]}):null]},i)),o.length?n("section",{class:"mt-8",children:[n("h2",{class:"eyebrow m-0 flex items-center gap-2",children:[n(_,{name:"terminal",class:"size-4"}),q.environment.title,n("a",{href:"#environment",onClick:i=>{i.preventDefault(),D("environment")},class:"focus-ring ml-auto text-xs font-medium tracking-normal text-brand normal-case hover:underline",children:"Open page"})]}),o.map(i=>n("div",{children:[n("h3",{class:"mt-4 mb-1.5 text-sm font-semibold text-ink",children:i.title}),i.note?n("p",{class:"mb-1.5 text-xs text-faint",children:i.note}):null,n(Qe,{vars:i.vars})]},i.title))]}):null]}):n(x,{children:[n(ke,{children:"No matches"}),n("p",{class:"mt-8 text-muted",children:['Nothing matches "',d.query,'". Try a shorter word, or press Esc to go back.']})]})}function Yn(){if(d.query.trim())return n(br,{});if(d.page==="get-running")return n(Vn,{});if(d.page==="environment")return n(_r,{});let e=O(d.page);return e?n(hr,{sec:e}):n(Vn,{})}function xr(){let e=d.path.split(/[\\/]/).pop()||"10xgraph.json",t=d.path.slice(0,d.path.length-e.length),o=null;return d.schema?d.exists?W()?o=n(ie,{class:"bg-warn-soft text-warn",children:"Unsaved"}):o=n(ie,{class:"bg-ok-soft text-ok",children:"Saved"}):o=n(ie,{class:"bg-brand-soft text-brand",children:"New"}):o=null,n("div",{class:"flex min-w-0 flex-1 items-center gap-2",title:d.path||void 0,children:[n(_,{name:"file",class:"size-4 text-faint"}),n("span",{class:"truncate font-mono text-[13px] font-medium text-ink",children:e}),o,t?n("span",{class:"hidden min-w-0 truncate font-mono text-xs text-faint lg:block",dir:"rtl",children:n("bdi",{children:t})}):null]})}function Xn(){let e=!!d.schema&&!d.fatal,t=d.sideOpen?"Hide sidebar":"Show sidebar";return n("header",{class:"sticky top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface px-3 sm:gap-3 sm:px-4",children:[n("button",{type:"button",class:"icon-btn","aria-controls":"side","aria-expanded":d.sideOpen?"true":"false","aria-label":t,title:`${t} (Ctrl/Cmd+B)`,onClick:()=>se(!d.sideOpen),children:n(_,{name:"panel",class:"size-[18px]"})}),n("div",{class:"flex shrink-0 items-center gap-2.5",children:[n("svg",{viewBox:"0 0 48 48",class:"size-7 shrink-0 text-ink","aria-hidden":"true",children:[n("rect",{x:"4",y:"4",width:"40",height:"40",rx:"11",fill:"none",stroke:"currentColor","stroke-width":"4.4"}),n("path",{d:"M13 33L21 23L34 15L34 31L21 23",fill:"none",stroke:"currentColor","stroke-width":"3","stroke-linecap":"round","stroke-linejoin":"round"}),n("g",{fill:"currentColor",children:[n("circle",{cx:"21",cy:"23",r:"4"}),n("circle",{cx:"34",cy:"15",r:"4"}),n("circle",{cx:"34",cy:"31",r:"4"})]}),n("circle",{cx:"13",cy:"33",r:"4.6",class:"fill-brand-amber"})]}),n("div",{class:"hidden items-baseline gap-1.5 sm:flex",children:[n("span",{class:"text-[15px] font-semibold tracking-tight text-ink",children:"10xGraph"}),n("span",{class:"text-[15px] text-faint",children:"Config"})]})]}),n("span",{class:"hidden h-6 w-px shrink-0 bg-line md:block","aria-hidden":"true"}),n(xr,{}),n("button",{type:"button",class:"btn btn-ghost","aria-label":"View 10xgraph.json","aria-haspopup":"dialog",disabled:!e,onClick:()=>kn(),children:[n(_,{name:"code",class:"size-[18px]"}),n("span",{class:"hidden md:inline",children:"View 10xgraph.json"})]}),n("button",{type:"button",class:"btn btn-primary",onClick:He,disabled:!e||!!d.busy||d.exists&&!W()&&!d.parseError,children:[d.busy==="save"?n(_,{name:"spinner",class:"size-4 animate-spin"}):null,d.exists?"Save":"Create file",n("span",{class:"kbd hidden lg:inline-flex",children:"Ctrl S"})]})]})}function Qn(){return d.fatal?n("div",{class:"panel mt-10",children:n(Tn,{icon:"xCircle",tint:"text-danger",title:d.fatal.title,text:d.fatal.text})}):d.schema?n(x,{children:[d.parseError?n("div",{class:"mb-6",children:n(ve,{kind:"warn",title:"The existing file is not valid JSON",lines:[`${d.parseError}. The form starts from defaults; saving replaces the file and keeps the old one as 10xgraph.json.bak.`]})}):null,n(Yn,{})]}):n("div",{class:"animate-pulse space-y-4 pt-2","aria-label":"Loading",children:[n("div",{class:"h-7 w-48 rounded-md bg-raised"}),n("div",{class:"h-4 w-80 max-w-full rounded-md bg-raised"}),[0,1,2].map(e=>n("div",{class:"h-16 rounded-xl bg-raised"},e))]})}function vr({id:e}){let t=!!O(e)&&pn(O(e)),o=mn(e);return!t&&!o?null:n("span",{class:"flex shrink-0 gap-1",children:[t?n("span",{class:"size-2 rounded-full bg-brand",title:O(e).self_managed?"Configured":"Changed from default"}):null,o?n("span",{class:`size-2 rounded-full ${o.level==="error"?"bg-danger":"bg-warn"}`,title:o.level==="error"?"Needs attention":"Has a warning"}):null]})}function yr({id:e}){let t=ge(e);if(!t)return null;let o=!d.query&&d.page===e;return n("a",{href:`#${e}`,"aria-current":o?"page":void 0,onClick:r=>{r.preventDefault(),D(e)},ref:r=>{r&&o&&window.innerWidth<1024&&r.scrollIntoView({block:"nearest",inline:"nearest"})},class:`focus-ring flex min-h-10 items-center gap-2.5 rounded-lg px-3 py-1.5 font-medium transition-colors duration-150 max-lg:min-h-11 max-lg:shrink-0 max-lg:whitespace-nowrap ${o?"bg-brand-soft text-ink":"text-muted hover:bg-raised hover:text-ink"}`,children:[n(_,{name:ee[e]||"box",class:`size-[18px] ${o?"text-brand":""}`}),n("span",{class:"min-w-0 flex-1",children:[n("span",{class:"block truncate",children:t.title}),t.subtitle?n("span",{class:"block truncate text-xs font-normal text-faint max-lg:hidden",children:t.subtitle}):null]}),n(vr,{id:e})]})}function Zn(){return d.sideRender?n("aside",{id:"side","aria-label":"Settings navigation",class:`contents lg:sticky lg:top-14 lg:block lg:h-[calc(100dvh-3.5rem)] lg:overflow-y-auto lg:border-r lg:border-line lg:bg-surface lg:px-3 lg:pt-4 lg:pb-16 lg:transition-[transform,opacity] lg:duration-200 lg:ease-out ${d.sideShown?"lg:translate-x-0 lg:opacity-100":"lg:-translate-x-6 lg:opacity-0"}`,inert:!d.sideOpen,children:[n("div",{role:"search",class:"relative mx-1 mb-3 max-lg:mx-4 max-lg:mt-3",children:[n(_,{name:"search",class:"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-faint"}),n("label",{for:"q",class:"sr-only",children:"Search settings"}),n("input",{id:"q",type:"search",value:d.query,autocomplete:"off",spellcheck:!1,placeholder:"Search settings ( / )",class:"input pl-10",onInput:e=>Fe(e.currentTarget.value)})]}),n("nav",{"aria-label":"Pages",class:"max-lg:sticky max-lg:top-14 max-lg:z-20 max-lg:mt-3 max-lg:flex max-lg:gap-0.5 max-lg:overflow-x-auto max-lg:border-b max-lg:border-line max-lg:bg-surface max-lg:px-3 max-lg:py-1.5",children:d.nav.map(e=>n("div",{class:"max-lg:contents",children:[e.label?n("div",{class:"mt-4 mb-1 px-3 text-[11px] font-semibold tracking-wider text-faint uppercase max-lg:hidden",children:e.label}):null,e.items.map(t=>n(yr,{id:t},t))]},e.label||"top"))})]}):null}var kr={success:["checkCircle","text-ok"],warning:["alert","text-warn"],error:["xCircle","text-danger"]};function wr(){return n("div",{class:"pointer-events-none fixed right-4 bottom-4 left-4 z-50 flex flex-col items-end gap-2 sm:left-auto","aria-live":"polite",children:d.toasts.map(e=>{let[t,o]=kr[e.kind];return n("div",{role:e.kind==="error"?"alert":"status",class:"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-sm shadow-pop",children:[n(_,{name:t,class:`mt-0.5 size-4 ${o}`}),n("p",{class:"min-w-0 flex-1 leading-5 text-body",children:e.message}),e.action?n("button",{type:"button",class:"shrink-0 font-medium text-brand hover:underline",onClick:()=>{qe(e.id),e.action.run()},children:e.action.label}):null,n("button",{type:"button",class:"-mr-1 shrink-0 cursor-pointer rounded text-faint hover:text-ink","aria-label":"Dismiss",onClick:()=>qe(e.id),children:n(_,{name:"x"})})]},e.id)})})}var Cr=()=>/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)||document.activeElement?.isContentEditable;function Sr(){let[,e]=dt(o=>o+1,0);U(()=>{dn(e);try{localStorage.getItem("tg-sidebar")==="hidden"&&se(!1,{instant:!0})}catch{}let o=s=>{let a=s.ctrlKey||s.metaKey,u=s.key.toLowerCase();a&&u==="s"?(s.preventDefault(),He()):a&&u==="b"?(s.preventDefault(),se(!d.sideOpen)):a&&u==="k"?(s.preventDefault(),gt()):s.key==="/"&&!a&&!s.altKey&&!Cr()&&!d.drawerOpen?(s.preventDefault(),gt()):s.key==="Escape"&&d.query&&!d.drawerOpen&&Fe("")},r=s=>{d.schema&&W()&&s.preventDefault()},i=()=>ze();return document.addEventListener("keydown",o),window.addEventListener("beforeunload",r),window.addEventListener("hashchange",i),xn(),()=>{document.removeEventListener("keydown",o),window.removeEventListener("beforeunload",r),window.removeEventListener("hashchange",i)}},[]),U(()=>{if(!d.schema||d.validated===L())return;let o=setTimeout(vn,400);return()=>clearTimeout(o)});let t=d.sideRender;return n(x,{children:[n("a",{href:"#main",class:"sr-only z-50 rounded-md bg-brand-solid px-3 py-2 text-white focus:not-sr-only focus:fixed focus:top-3 focus:left-3",children:"Skip to content"}),n(Xn,{}),n("div",{class:t?"lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:items-start":"",children:[n(Zn,{}),n("main",{id:"main",tabindex:-1,class:"min-w-0 px-4 pt-6 pb-24 focus:outline-none sm:px-8 sm:pt-8",children:n("div",{class:"mx-auto w-full max-w-[760px]",children:n(Qn,{})})})]}),n($n,{}),n("div",{class:"sr-only",role:"status","aria-live":"polite",children:d.copied?"Copied":""}),n(wr,{})]})}Vt(n(Sr,{}),document.getElementById("app"));
diff --git a/tenxgraph_api/cli/config_editor/validation.py b/tenxgraph_api/cli/config_editor/validation.py
index 66494a1..d5cc286 100644
--- a/tenxgraph_api/cli/config_editor/validation.py
+++ b/tenxgraph_api/cli/config_editor/validation.py
@@ -30,6 +30,7 @@
_ROLE_IN_MESSAGE = re.compile(r"role '([^']+)'")
_OBSERVABILITY_LEVELS = ("spans", "standard", "full")
_USER_ID_MODES = ("raw", "hash", "mask")
+_TRACE_DESTINATIONS = ("logfire", "langsmith", "phoenix", "otlp")
_MAX_PERCENT = 100
# Settings that live inside cache_store; at the top level the server refuses them.
_CACHE_STORE_SETTINGS = ("redis", "rate_limit", "token_revocation")
@@ -79,6 +80,7 @@ def validate_config(config: Any, base_dir: Path | None = None) -> list[Issue]:
_check_ag_ui(config, out)
_check_media(config, out)
_check_observability(config, out)
+ _check_metrics(config, out)
_check_wiring(config, out, base_dir)
_check_remote_tools(config, out)
_check_test(config, out)
@@ -301,8 +303,8 @@ def _check_observability(config: dict, out: _Collector) -> None:
"observability.user_id_mode",
f"user_id_mode must be one of {', '.join(_USER_ID_MODES)}.",
)
- any_enabled = False
- for backend in ("logfire", "langsmith"):
+ enabled_blocks: dict[str, dict] = {}
+ for backend in _TRACE_DESTINATIONS:
block = data.get(backend)
if block is None:
continue
@@ -312,7 +314,8 @@ def _check_observability(config: dict, out: _Collector) -> None:
enabled = block.get("enabled", False)
if not isinstance(enabled, bool):
out.error(f"observability.{backend}.enabled", "enabled must be true or false.")
- any_enabled = any_enabled or enabled is True
+ if enabled is True:
+ enabled_blocks[backend] = block
console = block.get("console")
if backend == "logfire" and console is not None and console is not False:
out.error(
@@ -320,12 +323,73 @@ def _check_observability(config: dict, out: _Collector) -> None:
"console must be false or left out. Logfire takes False or a ConsoleOptions "
"object, which JSON cannot express.",
)
- if not any_enabled:
+ if "otlp" in enabled_blocks:
+ _check_otlp_tracing(config, enabled_blocks["otlp"], out)
+ if not enabled_blocks:
out.warning(
- "observability", "Neither Logfire nor LangSmith is enabled, so nothing is traced."
+ "observability",
+ "None of Logfire, LangSmith, Phoenix or OTLP is enabled, so nothing is traced.",
)
+def _check_otlp_tracing(config: dict, otlp: dict, out: _Collector) -> None:
+ shared = otlp.get("use_metrics_endpoint")
+ if shared is not None and not isinstance(shared, bool):
+ out.error(
+ "observability.otlp.use_metrics_endpoint", "use_metrics_endpoint must be true or false."
+ )
+ return
+ endpoint = otlp.get("endpoint")
+ if shared:
+ metrics = config.get("metrics")
+ metrics = metrics if isinstance(metrics, dict) else {}
+ otlp_cfg = metrics.get("otlp")
+ url = otlp_cfg.get("endpoint") if isinstance(otlp_cfg, dict) else None
+ if not (metrics.get("enabled") is True and metrics.get("export") == "otlp" and url):
+ out.warning(
+ "observability.otlp.use_metrics_endpoint",
+ "Metrics are not set to OTLP push with an endpoint, so there is no endpoint to "
+ "share and the OTLP destination stays off.",
+ )
+ return
+ endpoint = url
+ elif not endpoint:
+ if not os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"):
+ out.error(
+ "observability.otlp.endpoint",
+ "OTLP needs an endpoint, or turn on Use the metrics endpoint.",
+ )
+ return
+ if isinstance(endpoint, str) and "/api/v1/otlp" in endpoint:
+ out.warning(
+ "observability.otlp.use_metrics_endpoint" if shared else "observability.otlp.endpoint",
+ "This is a Prometheus OTLP URL. Prometheus takes metrics only, so trace exports "
+ "will fail. Use a collector (OTel Collector, Grafana Alloy) that takes both.",
+ )
+
+
+def _check_metrics(config: dict, out: _Collector) -> None:
+ data = config.get("metrics")
+ if data is None:
+ return
+ if not isinstance(data, dict):
+ out.error("metrics", "metrics must be an object.")
+ return
+ from pydantic import ValidationError
+
+ from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
+
+ try:
+ MetricsConfig.model_validate(data)
+ except ValidationError as exc:
+ for err in exc.errors():
+ message = err["msg"].removeprefix("Value error, ")
+ path = ".".join(["metrics", *(str(part) for part in err["loc"])])
+ # A rule on the whole model names its field in the message.
+ named = re.match(r"(metrics\.[\w.]+) ", message)
+ out.error(named.group(1) if named else path, message)
+
+
def _check_wiring(config: dict, out: _Collector, base_dir: Path | None) -> None:
for key in ("injectq", "store", "checkpointer"):
value = config.get(key)
diff --git a/tenxgraph_api/cli/main.py b/tenxgraph_api/cli/main.py
index f102758..498be6e 100644
--- a/tenxgraph_api/cli/main.py
+++ b/tenxgraph_api/cli/main.py
@@ -536,30 +536,40 @@ def init( # noqa: PLR0913
"--name",
help="Agent name; required only when a default cannot be inferred.",
),
+ mode: str | None = typer.Option(
+ None,
+ "--mode",
+ help="Setup: quick (default) or full (storage, API security, evals, tests).",
+ ),
template: str | None = typer.Option(
None,
"--template",
- help="Project template: quick-start or production.",
+ help="Alias of --mode: quick-start is quick, production is full.",
),
auth: str | None = typer.Option(
None,
"--auth",
- help="Production authentication: none, jwt, or custom.",
+ help="Full mode API authentication: none, jwt (default), or custom.",
+ ),
+ storage: str | None = typer.Option(
+ None,
+ "--storage",
+ help="Full mode storage: memory (default), sqlite, postgres, or postgres-memory.",
),
cache_store: str | None = typer.Option(
None,
"--cache-store",
- help="Production cache store: memory or redis (default redis, URL from REDIS_URL).",
+ help="Full mode cache store: memory or redis (default follows --storage).",
),
rate_limit: bool | None = typer.Option(
None,
"--rate-limit/--no-rate-limit",
- help="Production rate limiting (default on).",
+ help="Full mode rate limiting (default on).",
),
token_revocation: bool | None = typer.Option(
None,
"--token-revocation/--no-token-revocation",
- help="Production token revocation (default on with jwt or custom auth).",
+ help="Full mode token revocation (default on with jwt or custom auth).",
),
provider: str | None = typer.Option(
None,
@@ -571,11 +581,36 @@ def init( # noqa: PLR0913
"--websocket/--no-websocket",
help="Enable WebSocket streaming and realtime audio (adds the websocket extra).",
),
+ evals: bool | None = typer.Option(
+ None,
+ "--evals/--no-evals",
+ help="Full mode: scaffold evals (default on).",
+ ),
+ tests: bool | None = typer.Option(
+ None,
+ "--tests/--no-tests",
+ help="Full mode: scaffold tests (default on).",
+ ),
+ ag_ui: bool | None = typer.Option(
+ None,
+ "--ag-ui/--no-ag-ui",
+ help="Full mode: enable the AG-UI endpoint (default off).",
+ ),
+ otel: bool | None = typer.Option(
+ None,
+ "--otel/--no-otel",
+ help="Full mode: add OpenTelemetry (default off).",
+ ),
+ worker: str | None = typer.Option(
+ None,
+ "--worker",
+ help="Full mode background worker: none (default), redis, rabbitmq, or kafka.",
+ ),
yes: bool = typer.Option(
False,
"--yes",
"-y",
- help="Accept recommended defaults and do not prompt.",
+ help="Accept recommended defaults and do not prompt (quick unless --mode full).",
),
non_interactive: bool = typer.Option(
False,
@@ -614,12 +649,19 @@ def init( # noqa: PLR0913
force=force,
agent_name=name,
template=template,
+ mode=mode,
auth=auth,
+ storage=storage,
cache_store=cache_store,
rate_limit=rate_limit,
token_revocation=token_revocation,
provider=provider,
websocket=websocket,
+ evals=evals,
+ tests=tests,
+ ag_ui=ag_ui,
+ otel=otel,
+ worker=worker,
yes=yes,
non_interactive=non_interactive,
dry_run=dry_run,
diff --git a/tenxgraph_api/cli/templates/prod/.env.example b/tenxgraph_api/cli/templates/prod/.env.example
index 1aacf13..a9cab4d 100644
--- a/tenxgraph_api/cli/templates/prod/.env.example
+++ b/tenxgraph_api/cli/templates/prod/.env.example
@@ -57,18 +57,31 @@ SNOWFLAKE_WORKER_ID=2
#################################
SENTRY_DSN=""
+##[IF_POSTGRES]##
#################################
###### Postgres Config ##########
#################################
-# Durable thread state (PgCheckpointer). Local example value; replace it in production.
+# Durable thread state, plus the long-term memory store when enabled. Local example value;
+# replace it in production.
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/agent"
+##[/IF_POSTGRES]##
+##[IF_REDIS]##
#################################
###### Redis Config #############
#################################
-# The checkpointer's hot thread cache and the API's cache store (rate limiting, run locks and
-# caps, token revocation). Local example value; replace it in production.
+# Hot thread cache for the Postgres checkpointer and the API's cache store (rate limiting,
+# run locks and caps, token revocation), plus background task status and results. Local example value; replace it in production.
REDIS_URL="redis://localhost:6379/0"
+##[/IF_REDIS]##
+
+##[IF_BROKER_URL]##
+#################################
+###### Broker Config ############
+#################################
+# Message broker for background tasks. Local example value; replace it in production.
+BROKER_URL="$broker_url_example"
+##[/IF_BROKER_URL]##
##[IF_JWT]##
#################################
@@ -82,3 +95,16 @@ JWT_ALGORITHM="HS256"
###### API Keys #################
#################################
$llm_api_key_env=""
+##[IF_EMBED_KEY]##
+# Embeddings for the memory store use OpenAI.
+OPENAI_API_KEY=""
+##[/IF_EMBED_KEY]##
+
+##[IF_OTEL]##
+#################################
+###### OpenTelemetry ############
+#################################
+OTEL_ENABLED="true"
+OTEL_SERVICE_NAME="$agent_name_slug"
+OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
+##[/IF_OTEL]##
diff --git a/tenxgraph_api/cli/templates/prod/graph/agent.py b/tenxgraph_api/cli/templates/prod/graph/agent.py
index 049ac67..54fe371 100644
--- a/tenxgraph_api/cli/templates/prod/graph/agent.py
+++ b/tenxgraph_api/cli/templates/prod/graph/agent.py
@@ -1,9 +1,28 @@
+# ##[IF_POSTGRES]##
import os
+# ##[/IF_POSTGRES]##
from datetime import datetime
from tenxgraph.core.state import MessageContextManager
from tenxgraph.prebuilt.agent import ReactAgent
+# ##[IF_POSTGRES]##
from tenxgraph.storage.checkpointer import PgCheckpointer
+# ##[/IF_POSTGRES]##
+# ##[IF_SQLITE]##
+from tenxgraph.storage.checkpointer import SqliteCheckpointer
+# ##[/IF_SQLITE]##
+# ##[IF_INMEM]##
+from tenxgraph.storage.checkpointer import InMemoryCheckpointer
+# ##[/IF_INMEM]##
+# ##[IF_STORE]##
+from tenxgraph.storage.store import PgVectorStore
+# ##[/IF_STORE]##
+# ##[IF_EMBED_GOOGLE]##
+from tenxgraph.storage.store import GoogleEmbedding
+# ##[/IF_EMBED_GOOGLE]##
+# ##[IF_EMBED_OPENAI]##
+from tenxgraph.storage.store import OpenAIEmbedding
+# ##[/IF_EMBED_OPENAI]##
from dotenv import load_dotenv
from injectq import InjectQ
@@ -14,6 +33,8 @@
load_dotenv()
+# Storage is chosen at init; run `10xgraph config` to review or change it.
+# ##[IF_POSTGRES]##
# Durable thread state: Postgres keeps every thread, Redis caches the hot ones.
# Set DATABASE_URL and REDIS_URL in .env (see .env.example). The tables are created (or
# upgraded) when the server starts, so a fresh database needs no setup step.
@@ -21,12 +42,30 @@
postgres_dsn=os.getenv("DATABASE_URL"),
redis_url=os.getenv("REDIS_URL"),
)
+# ##[/IF_POSTGRES]##
+# ##[IF_SQLITE]##
+# Durable thread state in a single local file. Fine for one process; use Postgres to run
+# several workers.
+checkpointer = SqliteCheckpointer(db_path="agent.db")
+# ##[/IF_SQLITE]##
+# ##[IF_INMEM]##
+# Threads live only in this process: they are lost on restart and not shared between
+# workers. Switch to a durable checkpointer before deploying.
+checkpointer = InMemoryCheckpointer()
+# ##[/IF_INMEM]##
+# ##[IF_STORE]##
-# Local testing without Postgres and Redis: comment out the block above and use this.
-# Threads live only in this process (lost on restart, not shared between workers),
-# so switch back to PgCheckpointer once you are ready to deploy.
-# from tenxgraph.storage.checkpointer import InMemoryCheckpointer
-# checkpointer = InMemoryCheckpointer()
+# Long-term memory in the same Postgres (pgvector).
+store = PgVectorStore(
+ # ##[IF_EMBED_GOOGLE]##
+ embedding=GoogleEmbedding(),
+ # ##[/IF_EMBED_GOOGLE]##
+ # ##[IF_EMBED_OPENAI]##
+ embedding=OpenAIEmbedding(),
+ # ##[/IF_EMBED_OPENAI]##
+ dsn=os.getenv("DATABASE_URL"),
+)
+# ##[/IF_STORE]##
# Dependency injection container; 10xgraph.json points at it ("injectq": "graph.agent:container").
container = InjectQ.get_instance()
@@ -64,5 +103,8 @@
app = react_agent.compile(
checkpointer=checkpointer,
+ # ##[IF_STORE]##
+ store=store,
+ # ##[/IF_STORE]##
callback_manager=callback_manager,
)
diff --git a/tenxgraph_api/cli/templates/prod/tests/conftest.py b/tenxgraph_api/cli/templates/prod/tests/conftest.py
index 1f1d0b9..d934410 100644
--- a/tenxgraph_api/cli/templates/prod/tests/conftest.py
+++ b/tenxgraph_api/cli/templates/prod/tests/conftest.py
@@ -4,6 +4,13 @@
os.environ.setdefault("$llm_api_key_env", "test-key")
+# ##[IF_POSTGRES]##
# PgCheckpointer connects lazily, so these only need to be well-formed to import the graph.
os.environ.setdefault("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/agent")
+# ##[/IF_POSTGRES]##
+# ##[IF_REDIS]##
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
+# ##[/IF_REDIS]##
+# ##[IF_EMBED_KEY]##
+os.environ.setdefault("OPENAI_API_KEY", "test-key")
+# ##[/IF_EMBED_KEY]##
diff --git a/tenxgraph_api/cli/templates/prod/worker/__init__.py b/tenxgraph_api/cli/templates/prod/worker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tenxgraph_api/cli/templates/prod/worker/tasks.py b/tenxgraph_api/cli/templates/prod/worker/tasks.py
new file mode 100644
index 0000000..c5d56cd
--- /dev/null
+++ b/tenxgraph_api/cli/templates/prod/worker/tasks.py
@@ -0,0 +1,45 @@
+"""Background tasks for this project.
+
+Run a worker with `10xgraph worker`. Run `10xgraph scheduler` for the scheduled tasks, and run
+exactly one scheduler or each schedule fires once per scheduler.
+
+Start a task over HTTP: POST /v1/tasks/summarize_thread with {"kwargs": {"thread_id": "..."}}.
+Start it from Python: `await summarize_thread.kiq(thread_id="...")`.
+"""
+
+import logging
+from typing import Annotated
+
+from injectq.integrations.taskiq import InjectTaskiq
+from taskiq import TaskiqDepends
+from taskiq.depends.progress_tracker import ProgressTracker, TaskState
+from tenxgraph.core.graph import CompiledGraph
+from tenxgraph.core.state import Message
+from tenxgraph_api.jobs import TaskContext, broker
+
+
+logger = logging.getLogger(__name__)
+
+
+@broker.task(task_name="summarize_thread", http=True)
+async def summarize_thread(
+ thread_id: str, # set by the caller
+ config: dict, # filled by the server: user_id, user, task_id
+ ctx: Annotated[TaskContext, TaskiqDepends()],
+ tracker: Annotated[ProgressTracker, TaskiqDepends()],
+ graph: Annotated[CompiledGraph, InjectTaskiq(CompiledGraph)],
+) -> dict:
+ if await ctx.cancelled(): # a running task stops only if it checks
+ return {"cancelled": True}
+ await tracker.set_progress(TaskState.STARTED, {"step": "summarizing"})
+ await graph.ainvoke(
+ {"messages": [Message.text_message("Summarize this conversation in two sentences.")]},
+ config={**config, "thread_id": thread_id},
+ )
+ return {"thread_id": thread_id, "task_id": ctx.task_id}
+
+
+@broker.task(schedule=[{"cron": "0 2 * * *"}]) # every day at 02:00, sent by `10xgraph scheduler`
+async def nightly_cleanup() -> None:
+ # Put cleanup work here, for example deleting old threads.
+ logger.info("nightly cleanup ran")
diff --git a/tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md b/tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md
index 29f6067..63d25f0 100644
--- a/tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md
+++ b/tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md
@@ -28,6 +28,13 @@ Publishers receive structured events from graph/tool execution. Use them for tra
to stdout by default; pass `ConsolePublisher({"use_logger": True})` to route events through the
`tenxgraph.publisher` logger instead of stdout.
+Publisher kinds:
+
+- `BasePublisher.kind` is `"events"` by default (type `PublisherKind = Literal["events", "observability"]`). `OtelPublisher` sets `"observability"`, so `LogfirePublisher`, `LangsmithPublisher` and `PhoenixPublisher` have it too. A custom tracing backend sets `kind = "observability"`.
+- `publish_event(event, only="events")` sends to event publishers only; `only="observability"` to tracing publishers only; `None` (default) to all. It works inside a `CompositePublisher`, also nested.
+- Event publishers (Kafka, Redis, RabbitMQ, console, custom) are bound in code; there is no `10xgraph.json` key. Tracing publishers come from the `observability` block. `metrics` never goes through publishers.
+- Routing: graph, node, LLM and tool events (AI errors included) go to event publishers and tracing, plus logs and LLM metrics. Task lifecycle events (queued, started, finished, saved, cancelled) go to event publishers only (`only="events"`), plus task metrics and logs. A task failure is not published: ERROR log and `tenxgraph.task.runs{status="failure"}`. API HTTP errors are not published: HTTP metrics and logs.
+
Realtime adds an `Event.REALTIME` event source and a `ContentType.TRANSCRIPT` content type (see
`realtime.md`).
@@ -71,9 +78,24 @@ ACP support is in `tenxgraph/runtime/protocols/acp.py`. Treat it as a runtime pr
- Events and spans keep content on purpose. Redis, RabbitMQ and Kafka publishers send full events (message text, tool I/O); send them only to trusted systems. OTEL records prompts and tool I/O only at `ObservabilityLevel.FULL`.
- TLS is the developer's choice, never forced. Recommend it in production whenever possible, and always on networks the developer does not control; a broker reachable only inside a secured private network (private subnet or VPC, or an encrypting service mesh) can run without it. Redis: `rediss://` URL. RabbitMQ: `amqps://` URL. Kafka: opt-in config keys `security_protocol`, `ssl_context`, `ssl_cafile`, `ssl_certfile`, `ssl_keyfile`, `sasl_mechanism`, `sasl_plain_username`, `sasl_plain_password` (passed to aiokafka only when set; `ssl_context` wins over the file keys).
-- `user_id_mode` (`"raw"` default, `"hash"`, `"mask"`) controls `user_id` on OTEL spans, and so on Logfire and LangSmith. It does not apply to Redis, RabbitMQ, Kafka or Console events. `hash` is the first 16 hex chars of SHA-256, or HMAC-SHA256 when env `TENXGRAPH_TRACE_USER_ID_KEY` is set (set it; plain SHA-256 of short ids can be guessed). Options: `OtelPublisher(level=..., user_id_mode=...)`, `setup_tracing`, `setup_logfire`, `setup_langsmith`, `setup_observability(graph, {"user_id_mode": ...})`. Invalid value raises `ValueError`.
+- `user_id_mode` (`"raw"` default, `"hash"`, `"mask"`) controls `user_id` on OTEL spans, and so on Logfire, LangSmith and Phoenix. It does not apply to Redis, RabbitMQ, Kafka or Console events. `hash` is the first 16 hex chars of SHA-256, or HMAC-SHA256 when env `TENXGRAPH_TRACE_USER_ID_KEY` is set (set it; plain SHA-256 of short ids can be guessed). Options: `OtelPublisher(level=..., user_id_mode=...)`, `setup_tracing`, `setup_logfire`, `setup_langsmith`, `setup_observability(graph, {"user_id_mode": ...})`. Invalid value raises `ValueError`.
- Server: `10xgraph.json` `"observability": {"user_id_mode": "hash"}`; also in the `10xgraph config` editor.
+## Phoenix (Arize)
+
+- Install `pip install '10xgraph[phoenix]'` (also in `10xgraph[observability]`). Enable in `10xgraph.json`: `"observability": {"phoenix": {"enabled": true, "project": "my-agent", "endpoint": null}}`.
+- `project` falls back to env `PHOENIX_PROJECT` / `PHOENIX_PROJECT_NAME`; `endpoint` is the base collector URL (env `PHOENIX_COLLECTOR_ENDPOINT`, then `http://localhost:6006`), `/v1/traces` is appended.
+- In Python: `setup_phoenix(graph, project=..., endpoint=..., api_key=...)` or `PhoenixPublisher(...)`, both from `tenxgraph.runtime.publisher`, used like `setup_langsmith` / `LangsmithPublisher`. Call before `graph.compile()`.
+- `PHOENIX_API_KEY` is env only and optional (a local Phoenix needs none). Never put it in the config file.
+
+## OTLP tracing and the metrics block
+
+- AI traces to any OTLP HTTP receiver (collector, Tempo, Jaeger, Grafana Cloud; not Prometheus): install `pip install '10xgraph[otlp]'`, config `"observability": {"otlp": {"enabled": true, "endpoint": "http://collector:4318"}}`. `/v1/traces` is appended. With a null endpoint, env `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is required (`OTEL_EXPORTER_OTLP_ENDPOINT` is not used). In Python: `setup_otlp(graph, endpoint=...)` before `graph.compile()`. Auth headers: env `OTEL_EXPORTER_OTLP_HEADERS` only.
+- `observability.otlp.use_metrics_endpoint: true` reuses `metrics.otlp.endpoint` (needs metrics on with `export: "otlp"`). The URL must take traces; a Prometheus `/api/v1/otlp` URL does not.
+- HTTP request spans never go to AI tracing; the server turns FastAPI's own telemetry off.
+- Metrics are separate from tracing: `"metrics": {"enabled": true, "export": "otlp" | "prometheus", "otlp": {"endpoint": ...}, "prometheus": {"path": "/metrics", "worker_port": 9464}, "latency_buckets_ms": [...]}`. Needs `pip install '10xgraph-api[metrics]'`. Prometheus scrape covers one process (API route, worker port); with several gunicorn or TaskIQ processes use OTLP push. The scrape route has no auth, keep it on a private network.
+- Core metrics include `tenxgraph.llm.duration` (provider, model, status) and `tenxgraph.llm.tokens` (provider, model, type); the task metrics are `tenxgraph.task.runs` and `tenxgraph.task.duration`.
+
## Rules
- Prefer publisher events for observability instead of ad hoc print/log statements in reusable runtime paths.
diff --git a/tenxgraph_api/jobs/monitoring.py b/tenxgraph_api/jobs/monitoring.py
index 22f0850..b59ff7f 100644
--- a/tenxgraph_api/jobs/monitoring.py
+++ b/tenxgraph_api/jobs/monitoring.py
@@ -1,8 +1,11 @@
-"""``MonitoringMiddleware``: log every TaskIQ hook and send the same as core publisher events
-when a ``BasePublisher`` is bound in the container. It also records two metrics through core's
-metrics facade (exported when OTEL is enabled): ``tenxgraph.task.runs`` (attributes ``task``,
-``status`` = queued, success, failure, cancelled) and ``tenxgraph.task.duration_ms`` (attribute
-``task``). Never raises."""
+"""``MonitoringMiddleware``: log every TaskIQ hook and send the lifecycle steps as core publisher
+events when a ``BasePublisher`` is bound in the container. Events go to event publishers only
+(``only="events"``), never to AI tracing publishers, which would take a task for a graph run.
+A task failure is not published as an event: it is logged at ERROR and counted. It also
+records two metrics through core's metrics facade (exported by the ``metrics`` block of
+10xgraph.json, or the legacy OTEL path):
+``tenxgraph.task.runs`` (attributes ``task``, ``status`` = queued, success, failure, cancelled)
+and ``tenxgraph.task.duration`` (attribute ``task``). Never raises."""
from __future__ import annotations
@@ -17,22 +20,21 @@
logger = logging.getLogger("tenxgraph_api.jobs")
-MESSAGE_LIMIT = 500
RUNS_METRIC = "tenxgraph.task.runs"
-DURATION_METRIC = "tenxgraph.task.duration_ms"
+DURATION_METRIC = "tenxgraph.task.duration"
class MonitoringMiddleware(TaskiqMiddleware):
- """Task lifecycle logs and events. Core has no task event kind yet, so events use
- ``Event.GRAPH_EXECUTION`` with ``metadata["source"] == "task"`` and the hook name in
- ``metadata["phase"]``.
+ """Task lifecycle logs and events (event publishers only). Core has no task event kind yet,
+ so events use ``Event.GRAPH_EXECUTION`` with ``metadata["source"] == "task"`` and the hook
+ name in ``metadata["phase"]``.
| hook | event type | phase |
|---|---|---|
| pre_send | none | |
| post_send | UPDATE | queued |
| pre_execute | START | started |
- | on_error | ERROR (INTERRUPTED for ``TaskCancelled``) | failed (cancelled) |
+ | on_error | none for a failure (log, metric); INTERRUPTED for ``TaskCancelled`` | cancelled |
| post_execute | END (success only) | finished |
| post_save | RESULT | saved |
"""
@@ -84,13 +86,6 @@ def on_error(
exc_info=exception,
)
self._count(message, "failure")
- data = {
- "error": type(exception).__name__,
- "message": str(exception)[:MESSAGE_LIMIT],
- "duration": took,
- "attempt": attempt,
- }
- self._publish(message, "error", "failed", data, is_error=True)
def post_execute(self, message: TaskiqMessage, result: TaskiqResult[Any]) -> None:
self._duration(message, result.execution_time)
@@ -137,7 +132,6 @@ def _publish(
event_type: str,
phase: str,
data: dict[str, Any] | None = None,
- is_error: bool = False,
) -> None:
try:
from tenxgraph.runtime.publisher.base_publisher import BasePublisher
@@ -156,10 +150,9 @@ def _publish(
node_name=message.task_name,
run_id=message.task_id,
user_id=state.parse_config(message.labels).get("user_id"),
- is_error=is_error,
data=data or {},
metadata={"source": "task", "task_name": message.task_name, "phase": phase},
)
- publish_event(event, publisher=publisher, task_manager=manager)
+ publish_event(event, publisher=publisher, task_manager=manager, only="events")
except Exception:
logger.debug("Task event not published", exc_info=True)
diff --git a/tenxgraph_api/jobs/worker.py b/tenxgraph_api/jobs/worker.py
index 087ee6f..87d705d 100644
--- a/tenxgraph_api/jobs/worker.py
+++ b/tenxgraph_api/jobs/worker.py
@@ -50,9 +50,11 @@ async def worker_startup(state: TaskiqState) -> None:
The ``taskiq worker`` CLI has imported the task modules before this event.
"""
+ from tenxgraph_api.src.app.core.config.metrics import setup_metrics
from tenxgraph_api.src.app.core.config.settings import get_settings
from tenxgraph_api.src.app.core.config.setup_middleware import (
_attach_otel_publisher,
+ _setup_observability,
setup_otel_provider,
)
from tenxgraph_api.src.app.loader import attach_all_modules, load_container
@@ -63,9 +65,15 @@ async def worker_startup(state: TaskiqState) -> None:
container = load_container(graph_config.injectq_path) or InjectQ.get_instance()
container.bind_instance(GraphConfig, graph_config)
settings = get_settings()
- if settings.OTEL_ENABLED: # same traces as the API; before the graph freezes BasePublisher
- setup_otel_provider(settings)
+ metrics_cfg = graph_config.metrics
+ state.meter_provider = (
+ setup_metrics(metrics_cfg, role="worker") if metrics_cfg.enabled else None
+ )
+ # Same traces as the API; before the graph freezes BasePublisher.
+ if settings.OTEL_ENABLED:
+ setup_otel_provider(settings, metrics_owned=state.meter_provider is not None)
_attach_otel_publisher(container, settings, graph_config)
+ _setup_observability(container, graph_config)
state.container = container
state.graph = await attach_all_modules(graph_config, container)
if settings.STARTUP_CHECKS:
@@ -75,8 +83,10 @@ async def worker_startup(state: TaskiqState) -> None:
async def worker_shutdown(state: TaskiqState) -> None:
- """Close the graph (and with it the checkpointer and store), then the cache store."""
+ """Close the graph (and with it the checkpointer and store), then the cache store, then
+ send the last metrics and AI spans."""
from tenxgraph_api.src.app.core.cache_store import BaseCacheStore
+ from tenxgraph_api.src.app.core.config.metrics import flush_telemetry
from tenxgraph_api.src.app.core.config.settings import get_settings
container = getattr(state, "container", None) or InjectQ.get_instance()
@@ -97,6 +107,8 @@ async def worker_shutdown(state: TaskiqState) -> None:
await cache_store.aclose()
except Exception as exc:
logger.warning("Cache store close failed: %s", exc)
+ # Last: taskiq bounds this hook (--shutdown-timeout), so a slow collector cuts only this.
+ await flush_telemetry(getattr(state, "meter_provider", None))
if worker_config is not None:
diff --git a/tenxgraph_api/src/app/core/config/graph_config.py b/tenxgraph_api/src/app/core/config/graph_config.py
index 25846d5..a2ff8b9 100644
--- a/tenxgraph_api/src/app/core/config/graph_config.py
+++ b/tenxgraph_api/src/app/core/config/graph_config.py
@@ -13,6 +13,7 @@
if TYPE_CHECKING:
from tenxgraph_api.jobs.config import WorkerConfig
from tenxgraph_api.src.app.core.auth.auth_config import AuthConfig
+ from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
logger = logging.getLogger("tenxgraph_api")
@@ -903,17 +904,18 @@ def remote_tools(self) -> list[RemoteToolConfig]:
@property
def observability(self) -> dict | None:
- """The declarative ``observability`` block (Logfire / LangSmith).
+ """The declarative ``observability`` block (Logfire / LangSmith / Phoenix).
Shape mirrors ``tenxgraph.runtime.publisher.setup_observability``::
{
"level": "standard",
"logfire": {"enabled": true, "service_name": "my-agent", ...},
- "langsmith": {"enabled": true, "project": "my-agent", "endpoint": null}
+ "langsmith": {"enabled": true, "project": "my-agent", "endpoint": null},
+ "phoenix": {"enabled": true, "project": "my-agent", "endpoint": null}
}
- Secrets (``LOGFIRE_TOKEN``, ``LANGSMITH_API_KEY``) stay in the
+ Secrets (``LOGFIRE_TOKEN``, ``LANGSMITH_API_KEY``, ``PHOENIX_API_KEY``) stay in the
environment and are never read from here.
"""
return self.data.get("observability", None)
@@ -975,6 +977,13 @@ def ag_ui(self) -> AgUiConfig:
data = self.data.get("ag_ui", None)
return AgUiConfig.from_dict({} if data is None else data)
+ @property
+ def metrics(self) -> "MetricsConfig":
+ """The ``metrics`` block (OTLP push or Prometheus scrape); off when absent."""
+ from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
+
+ return MetricsConfig.model_validate(self.data.get("metrics") or {})
+
@property
def media(self) -> MediaConfig:
"""Uploaded-file lifecycle settings (``media`` key); defaults when absent."""
diff --git a/tenxgraph_api/src/app/core/config/metrics.py b/tenxgraph_api/src/app/core/config/metrics.py
new file mode 100644
index 0000000..84c17b3
--- /dev/null
+++ b/tenxgraph_api/src/app/core/config/metrics.py
@@ -0,0 +1,365 @@
+"""The ``metrics`` block of 10xgraph.json and the MeterProvider it sets up (API and worker).
+
+Metrics (request rate, latency, task throughput, LLM latency) are separate from AI tracing
+(``observability``): they never carry spans, and HTTP spans never reach an AI tracing backend.
+"""
+
+import asyncio
+import inspect
+import logging
+import os
+import socket
+import time
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from .settings import get_settings, logger
+
+
+DEFAULT_BUCKETS_MS: list[float] = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000]
+METRICS_ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"
+OTLP_ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT"
+SEMCONV_ENV = "OTEL_SEMCONV_STABILITY_OPT_IN"
+INSTALL_HINT = "pip install '10xgraph-api[metrics]'"
+# Bound for the final metrics push and AI span flush at shutdown (each, run at the same time).
+TELEMETRY_FLUSH_SECONDS = 5.0
+
+
+class _Section(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class MetricsOtlpConfig(_Section):
+ """``metrics.otlp``: base URL of an OTLP HTTP receiver; ``/v1/metrics`` is appended."""
+
+ endpoint: str | None = None
+
+
+class MetricsPrometheusConfig(_Section):
+ """``metrics.prometheus``: ``path`` is the API scrape route, ``worker_port`` the worker's."""
+
+ path: str = Field(default="/metrics", pattern=r"^/")
+ worker_port: int = Field(default=9464, ge=1, le=65535)
+
+ @field_validator("path")
+ @classmethod
+ def _not_root(cls, path: str) -> str:
+ if path == "/":
+ raise ValueError('metrics.prometheus.path must not be "/"')
+ return path
+
+
+class MetricsConfig(_Section):
+ """``metrics`` in 10xgraph.json. Example::
+
+ "metrics": {
+ "enabled": true,
+ "export": "otlp",
+ "otlp": {"endpoint": "http://collector:4318"},
+ "prometheus": {"path": "/metrics", "worker_port": 9464},
+ "latency_buckets_ms": [50, 100, 250, 500, 1000, 2500, 5000]
+ }
+
+ Auth headers for OTLP come from the standard ``OTEL_EXPORTER_OTLP_HEADERS`` env var.
+ """
+
+ enabled: bool = False
+ export: Literal["otlp", "prometheus"] | None = None
+ otlp: MetricsOtlpConfig = Field(default_factory=MetricsOtlpConfig)
+ prometheus: MetricsPrometheusConfig = Field(default_factory=MetricsPrometheusConfig)
+ latency_buckets_ms: list[float] = Field(default_factory=lambda: list(DEFAULT_BUCKETS_MS))
+
+ @model_validator(mode="after")
+ def _check(self) -> "MetricsConfig":
+ buckets = self.latency_buckets_ms
+ if not buckets or buckets[0] <= 0 or buckets != sorted(set(buckets)):
+ raise ValueError("metrics.latency_buckets_ms must be positive and strictly ascending")
+ if self.enabled:
+ if self.export is None:
+ raise ValueError('metrics.export is required when enabled: "otlp" or "prometheus"')
+ if self.export == "otlp" and not (
+ self.otlp.endpoint
+ or os.environ.get(METRICS_ENDPOINT_ENV)
+ or os.environ.get(OTLP_ENDPOINT_ENV)
+ ):
+ raise ValueError(
+ "metrics.otlp.endpoint is required for export=otlp "
+ f"(or set {METRICS_ENDPOINT_ENV} or {OTLP_ENDPOINT_ENV})"
+ )
+ return self
+
+ @property
+ def scrape_path(self) -> str | None:
+ """The API route Prometheus scrapes; None unless metrics are on with export=prometheus."""
+ return self.prometheus.path if self.enabled and self.export == "prometheus" else None
+
+
+def _otlp_metrics_url(endpoint: str) -> str:
+ base = endpoint.rstrip("/")
+ return base if base.endswith("/v1/metrics") else base + "/v1/metrics"
+
+
+def resolve_use_metrics_endpoint(obs_cfg: dict, metrics_cfg: MetricsConfig) -> dict:
+ """Copy ``obs_cfg`` with ``otlp.endpoint`` taken from ``metrics.otlp.endpoint`` when
+ ``observability.otlp.use_metrics_endpoint`` is on. Without a usable metrics endpoint the
+ otlp destination is turned off in the copy (with a warning) instead of crashing."""
+ otlp = obs_cfg.get("otlp") or {}
+ if not otlp.get("enabled") or not otlp.get("use_metrics_endpoint"):
+ return obs_cfg
+ otlp = dict(otlp)
+ endpoint = None
+ if metrics_cfg.enabled and metrics_cfg.export == "otlp":
+ # The exporter reads the same env vars when metrics.otlp.endpoint is not set.
+ endpoint = (
+ metrics_cfg.otlp.endpoint
+ or os.environ.get(METRICS_ENDPOINT_ENV)
+ or os.environ.get(OTLP_ENDPOINT_ENV)
+ )
+ if endpoint:
+ # Core appends /v1/traces to the base URL.
+ base = endpoint.rstrip("/")
+ otlp["endpoint"] = base.removesuffix("/v1/metrics")
+ if "/api/v1/otlp" in base:
+ logger.warning(
+ "observability.otlp.use_metrics_endpoint points AI traces at a Prometheus OTLP "
+ "URL (%s); Prometheus accepts metrics only, so trace exports will fail. Use an "
+ "endpoint that takes both (OTel Collector, Grafana Alloy) or a separate one.",
+ endpoint,
+ )
+ else:
+ logger.warning(
+ "observability.otlp.use_metrics_endpoint needs metrics enabled with export=otlp and "
+ "an OTLP endpoint (metrics.otlp.endpoint or %s); the otlp trace destination is off.",
+ METRICS_ENDPOINT_ENV,
+ )
+ otlp["enabled"] = False
+ return {**obs_cfg, "otlp": otlp}
+
+
+# The provider of the last setup_metrics call; a rebuild in one process shuts it down first.
+_providers: list = []
+
+
+def _use_stable_http_names() -> None:
+ """Make sure ``OTEL_SEMCONV_STABILITY_OPT_IN`` has ``http`` (or ``http/dup``), keeping other
+ values, so HTTP metrics are ``http.server.request.duration`` (seconds). Must run before the
+ first instrumentation: OpenTelemetry reads the variable once and caches it."""
+ values = [v.strip() for v in os.environ.get(SEMCONV_ENV, "").split(",") if v.strip()]
+ if "http" not in values and "http/dup" not in values:
+ os.environ[SEMCONV_ENV] = ",".join([*values, "http"])
+ try:
+ from opentelemetry.instrumentation._semconv import (
+ _OpenTelemetrySemanticConventionStability as stability,
+ )
+ from opentelemetry.instrumentation._semconv import (
+ _OpenTelemetryStabilitySignalType,
+ _StabilityMode,
+ )
+
+ # The mode is cached on first use; a read before our merge may have missed "http".
+ cached = (
+ stability._initialized
+ and stability._get_opentelemetry_stability_opt_in_mode(
+ _OpenTelemetryStabilitySignalType.HTTP
+ )
+ == _StabilityMode.DEFAULT
+ )
+ except Exception: # a best-effort diagnostic: never block the setup
+ return
+ if cached:
+ logger.warning(
+ "OpenTelemetry read %s before the metrics block set it: HTTP metrics will use the "
+ "old names (http.server.duration, in ms). Set %s=http in the environment.",
+ SEMCONV_ENV,
+ SEMCONV_ENV,
+ )
+
+
+def _shutdown_previous_provider() -> None:
+ """Shut down the provider of an earlier setup in this process (app built twice in tests or
+ when embedding); this also unregisters its Prometheus collector, so families are not doubled."""
+ while _providers:
+ try:
+ _providers.pop().shutdown()
+ except Exception as exc:
+ logger.warning("Previous MeterProvider shutdown failed: %r", exc)
+
+
+def setup_metrics(metrics_cfg: MetricsConfig, *, role: Literal["api", "worker"]):
+ """Install the global MeterProvider and bridge core's counters and timers to it.
+
+ Returns the provider, or None when the OpenTelemetry packages are missing or the setup
+ fails (never raises).
+ """
+ _use_stable_http_names()
+ _shutdown_previous_provider()
+ try:
+ from opentelemetry import metrics
+ from tenxgraph.utils.metrics import setup_otel_metrics
+
+ provider = _build_provider(metrics_cfg)
+ except ImportError:
+ logger.warning("Metrics are not exported: packages are missing. Install: %s", INSTALL_HINT)
+ return None
+ except Exception as exc:
+ logger.warning("Metrics are not exported: setup failed: %s", exc)
+ return None
+
+ _providers.append(provider)
+ metrics.set_meter_provider(provider)
+ if metrics.get_meter_provider() is not provider:
+ # Set earlier by opentelemetry-instrument, logfire.configure() or an imported module.
+ logger.warning(
+ "Another global MeterProvider was installed before the metrics block; 10xgraph "
+ "and HTTP metrics still use the metrics block, other libraries use the other one."
+ )
+ # The provider itself, not the global one, so a refused set_meter_provider loses nothing.
+ setup_otel_metrics(provider.get_meter("10xgraph"))
+
+ if metrics_cfg.export == "prometheus":
+ _announce_scrape(metrics_cfg, role)
+ logger.info("Metrics enabled (export=%s, role=%s)", metrics_cfg.export, role)
+ return provider
+
+
+def _build_provider(metrics_cfg: MetricsConfig):
+ from opentelemetry.sdk.metrics import Histogram, MeterProvider
+ from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
+ from opentelemetry.sdk.resources import Resource
+
+ if metrics_cfg.export == "otlp":
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
+
+ endpoint = metrics_cfg.otlp.endpoint
+ exporter = OTLPMetricExporter(endpoint=_otlp_metrics_url(endpoint) if endpoint else None)
+ reader = PeriodicExportingMetricReader(exporter)
+ else:
+ from opentelemetry.exporter.prometheus import PrometheusMetricReader
+
+ reader = PrometheusMetricReader()
+
+ buckets_ms = metrics_cfg.latency_buckets_ms
+ views = [
+ View(
+ instrument_type=Histogram,
+ instrument_unit=unit,
+ aggregation=ExplicitBucketHistogramAggregation([b / divisor for b in buckets_ms]),
+ )
+ for unit, divisor in (("ms", 1), ("s", 1000))
+ ]
+ resource = Resource.create(
+ {
+ "service.name": get_settings().OTEL_SERVICE_NAME,
+ # One per process, so pushes from several processes do not overwrite each other.
+ "service.instance.id": f"{socket.gethostname()}-{os.getpid()}",
+ }
+ )
+ return MeterProvider(resource=resource, metric_readers=[reader], views=views)
+
+
+def _announce_scrape(metrics_cfg: MetricsConfig, role: str) -> None:
+ if role == "api":
+ from tenxgraph_api.src.app.loader import _web_concurrency
+
+ workers = _web_concurrency()
+ logger.log(
+ logging.WARNING if workers > 1 else logging.INFO,
+ "Prometheus scrape at %s covers this process only; with several gunicorn "
+ "workers (WEB_CONCURRENCY=%d) use OTLP push instead.",
+ metrics_cfg.prometheus.path,
+ workers,
+ )
+ return
+ port = metrics_cfg.prometheus.worker_port
+ try:
+ from prometheus_client import start_http_server
+
+ start_http_server(port)
+ except OSError as exc:
+ # TaskIQ starts several worker processes by default; only the first gets the port.
+ logger.warning(
+ "Metrics port %d is not available (%s): this worker process is not scraped. "
+ "Use OTLP push or run the worker with --workers 1.",
+ port,
+ exc,
+ )
+
+
+async def flush_telemetry(meter_provider) -> None:
+ """Last step of the API and worker shutdown: the final metrics push (MeterProvider
+ shutdown) and a flush of the AI tracing provider (force_flush only, so a provider owned by
+ Logfire or another library stays usable). Both run in threads at the same time, each
+ bounded by ``TELEMETRY_FLUSH_SECONDS``. Never raises; a timeout or failure is a WARNING."""
+ calls = []
+ if meter_provider is not None:
+ calls.append(_bounded("Metrics shutdown", meter_provider.shutdown))
+ tracer_provider = _tracer_provider()
+ if tracer_provider is not None:
+ calls.append(_bounded("AI tracing flush", tracer_provider.force_flush))
+ await asyncio.gather(*calls)
+
+
+def _tracer_provider():
+ try:
+ from opentelemetry import trace
+ except ImportError:
+ return None
+ provider = trace.get_tracer_provider()
+ # The default proxy (nothing configured) has no force_flush.
+ return provider if hasattr(provider, "force_flush") else None
+
+
+async def _bounded(what: str, call) -> None:
+ started = time.monotonic()
+ try:
+ # MeterProvider.shutdown honours timeout_millis; BatchSpanProcessor.force_flush does not
+ # (it waits for the exporter's own timeout), so wait_for is the real bound there.
+ result = await asyncio.wait_for(
+ asyncio.to_thread(call, timeout_millis=int(TELEMETRY_FLUSH_SECONDS * 1000)),
+ TELEMETRY_FLUSH_SECONDS,
+ )
+ except TimeoutError:
+ result = False
+ except Exception as exc:
+ logger.warning("%s failed: %r", what, exc)
+ return
+ # force_flush returns False on timeout; MeterProvider.shutdown returns None at the deadline.
+ if result is False or time.monotonic() - started >= TELEMETRY_FLUSH_SECONDS:
+ logger.warning(
+ "%s did not finish within %ss; the last data may be lost",
+ what,
+ TELEMETRY_FLUSH_SECONDS,
+ )
+
+
+def check_scrape_route(app, scrape_route) -> None:
+ """Refuse to start when another route answers on the scrape path. The scrape path is open
+ to the auth guard and the rate limit, so a real route there would lose both. Call after all
+ routers are included. ``scrape_route`` is the route the metrics block added (or None)."""
+ if scrape_route is None:
+ return
+ for route in app.routes:
+ regex = getattr(route, "path_regex", None)
+ if route is not scrape_route and regex is not None and regex.match(scrape_route.path):
+ raise ValueError(
+ f"metrics.prometheus.path {scrape_route.path!r} clashes with the route "
+ f"{route.path!r}; choose another path such as /_metrics."
+ )
+
+
+def native_telemetry_off() -> dict:
+ """Keyword arguments for ``FastAPI(...)`` that turn its native OpenTelemetry off.
+
+ Newer FastAPI (with a ``telemetry`` parameter) traces, meters and logs every request into
+ the global providers by default, so HTTP spans would reach an AI tracing backend (Phoenix,
+ LangSmith, OTLP, Logfire). HTTP spans come only from FastAPIInstrumentor on the legacy
+ ``OTEL_ENABLED`` path and HTTP metrics only from the ``metrics`` block. Empty on an older
+ FastAPI.
+ """
+ from fastapi import FastAPI
+
+ if "telemetry" not in inspect.signature(FastAPI.__init__).parameters:
+ return {}
+ return {"telemetry": {"tracing": False, "metrics": False, "logs": False}}
diff --git a/tenxgraph_api/src/app/core/config/setup_middleware.py b/tenxgraph_api/src/app/core/config/setup_middleware.py
index af7e790..545f1d7 100644
--- a/tenxgraph_api/src/app/core/config/setup_middleware.py
+++ b/tenxgraph_api/src/app/core/config/setup_middleware.py
@@ -1,4 +1,7 @@
+import os
+import re
import uuid
+from dataclasses import replace
from datetime import datetime
from typing import Any
@@ -19,6 +22,7 @@
from tenxgraph_api.src.app.core.middleware.security_headers import SecurityHeadersMiddleware
from .graph_config import GraphConfig
+from .metrics import resolve_use_metrics_endpoint, setup_metrics
from .sentry_config import init_sentry
from .settings import get_settings, logger
@@ -29,11 +33,16 @@ class HealthCheckAwareTrustedHostMiddleware(TrustedHostMiddleware):
Docker and Kubernetes probes call ``/ping`` and ``/health/ready`` on ``localhost`` or the
pod IP, which are never in ``ALLOWED_HOST``, so the probe would fail and the container would
be restarted. Both return only status words and read nothing from the request, so skipping
- the Host check there is safe.
+ the Host check there is safe. ``open_paths`` adds more such paths (the Prometheus scrape
+ route, scraped by pod IP or service name).
"""
+ def __init__(self, app, *, open_paths: tuple[str, ...] = (), **kwargs: Any) -> None:
+ super().__init__(app, **kwargs)
+ self.open_paths = (*HEALTH_PROBE_PATHS, *open_paths)
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] == "http" and _is_health_check(scope):
+ if scope["type"] == "http" and _is_health_check(scope, self.open_paths):
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
@@ -58,10 +67,10 @@ def _upload_path_limits(max_request_size: int) -> dict[str, int]:
HEALTH_PROBE_PATHS = ("/ping", "/health/ready")
-def _is_health_check(scope: Scope) -> bool:
+def _is_health_check(scope: Scope, paths: tuple[str, ...] = HEALTH_PROBE_PATHS) -> bool:
path = scope.get("path", "")
root_path = (scope.get("root_path") or "").rstrip("/")
- return any(path in (probe, f"{root_path}{probe}") for probe in HEALTH_PROBE_PATHS)
+ return any(path in (probe, f"{root_path}{probe}") for probe in paths)
# Paths that should be excluded from GZip compression (streaming endpoints)
@@ -296,7 +305,7 @@ def _attach_otel_publisher(
def _setup_observability(container: InjectQ | None, graph_config: GraphConfig | None) -> None:
- """Wire Logfire/LangSmith from the ``observability`` block of ``10xgraph.json``.
+ """Wire Logfire/LangSmith/Phoenix/OTLP from the ``observability`` block of ``10xgraph.json``.
Configures the tracer provider(s)/exporters and binds an ``OtelPublisher``
into the DI container. This runs at app-construction time, before the graph
@@ -306,7 +315,7 @@ def _setup_observability(container: InjectQ | None, graph_config: GraphConfig |
preserves a publisher already present in the container instead of clobbering
it with ``None``.
- Secrets stay in the environment (``LOGFIRE_TOKEN``, ``LANGSMITH_API_KEY``);
+ Secrets stay in the environment (``LOGFIRE_TOKEN``, ``LANGSMITH_API_KEY``, ``PHOENIX_API_KEY``);
only non-secret settings live in ``10xgraph.json``.
"""
if container is None or graph_config is None:
@@ -315,10 +324,13 @@ def _setup_observability(container: InjectQ | None, graph_config: GraphConfig |
obs_cfg = graph_config.observability
if not obs_cfg:
return
+ obs_cfg = resolve_use_metrics_endpoint(obs_cfg, graph_config.metrics)
logfire_on = bool((obs_cfg.get("logfire") or {}).get("enabled", False))
langsmith_on = bool((obs_cfg.get("langsmith") or {}).get("enabled", False))
- if not logfire_on and not langsmith_on:
+ phoenix_on = bool((obs_cfg.get("phoenix") or {}).get("enabled", False))
+ otlp_on = bool((obs_cfg.get("otlp") or {}).get("enabled", False))
+ if not (logfire_on or langsmith_on or phoenix_on or otlp_on):
return
try:
@@ -362,14 +374,16 @@ def _setup_observability(container: InjectQ | None, graph_config: GraphConfig |
_bind_publisher(container, CompositePublisher([existing, otel_publisher]))
logger.info(
- "Observability enabled (logfire=%s, langsmith=%s, level=%s)",
+ "Observability enabled (logfire=%s, langsmith=%s, phoenix=%s, otlp=%s, level=%s)",
logfire_on,
langsmith_on,
+ phoenix_on,
+ otlp_on,
level,
)
-def setup_otel_provider(settings) -> bool:
+def setup_otel_provider(settings, *, metrics_owned: bool = False) -> bool:
"""Configure OpenTelemetry traces and metrics (API and worker share this).
Metrics use a MeterProvider with the same resource and endpoint (OTLP gRPC, or the console
@@ -383,6 +397,9 @@ def setup_otel_provider(settings) -> bool:
configured and the app is not in production, so traces are visible locally
without any collector. In production with no endpoint nothing is set up and a warning
is logged. Returns True when a provider was installed.
+
+ With ``metrics_owned`` the ``metrics`` block already installed the MeterProvider, so no
+ second one is created here.
"""
try:
from opentelemetry import trace
@@ -427,7 +444,10 @@ def setup_otel_provider(settings) -> bool:
logger.info("OTEL: no endpoint configured, printing spans to console")
trace.set_tracer_provider(provider)
- _setup_otel_metrics(resource, endpoint)
+ if metrics_owned:
+ logger.info("OTEL: metrics come from the metrics block")
+ else:
+ _setup_otel_metrics(resource, endpoint)
return True
@@ -460,20 +480,90 @@ def _setup_otel_metrics(resource, endpoint: str | None) -> None:
logger.info("OTEL: metrics enabled")
-def _setup_otel(app: FastAPI, settings) -> None:
- """Configure OpenTelemetry tracing and instrument the FastAPI app."""
- if not setup_otel_provider(settings):
- return
+def _excluded_urls(app: FastAPI, scrape_path: str) -> str:
+ """Comma-separated URL regexes the instrumentation skips: the scrape route (so scraping does
+ not show up in the HTTP metrics) plus whatever the standard env variables already exclude.
+ Passing ``excluded_urls`` replaces those variables, hence they are merged here."""
+ own = rf"^\w+://[^/]*{re.escape(app.root_path)}{re.escape(scrape_path)}(\?|$)"
+ env = os.environ.get("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS") or os.environ.get(
+ "OTEL_PYTHON_EXCLUDED_URLS", ""
+ )
+ return f"{env},{own}" if env else own
+
+
+def _instrument_fastapi(
+ app: FastAPI, *, tracing: bool, meter_provider=None, scrape_path: str | None = None
+) -> bool:
+ """The one ``instrument_app`` call (it can run only once per app).
+
+ HTTP spans go to the global tracer provider only with the legacy ``OTEL_ENABLED`` path;
+ otherwise they go to a no-op one, so they never reach an AI tracing backend. HTTP metrics
+ go to ``meter_provider`` when given, else to the global one. ``scrape_path`` is left out of
+ both.
+ """
try:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
+ from opentelemetry.trace import NoOpTracerProvider
except ImportError:
logger.warning(
- "OTEL_ENABLED=true but opentelemetry packages are not installed. "
- "Install with: pip install '10xgraph-api[otel]'"
+ "FastAPI instrumentation skipped: opentelemetry packages are not installed. "
+ "Install with: pip install '10xgraph-api[otel]' or '10xgraph-api[metrics]'"
)
- return
- FastAPIInstrumentor.instrument_app(app)
+ return False
+ kwargs: dict[str, Any] = {}
+ if scrape_path:
+ kwargs["excluded_urls"] = _excluded_urls(app, scrape_path)
+ if not tracing:
+ kwargs["tracer_provider"] = NoOpTracerProvider()
+ if meter_provider is not None:
+ kwargs["meter_provider"] = meter_provider
+ FastAPIInstrumentor.instrument_app(app, **kwargs)
+ return True
+
+
+def _setup_otel(app: FastAPI, settings, meter_provider=None, scrape_path=None) -> bool:
+ """Configure OpenTelemetry tracing and instrument the FastAPI app.
+
+ ``meter_provider`` is the one from the ``metrics`` block; it then owns the metrics.
+ Returns True when the app was instrumented.
+ """
+ if not setup_otel_provider(settings, metrics_owned=meter_provider is not None):
+ return False
+ if not _instrument_fastapi(
+ app, tracing=True, meter_provider=meter_provider, scrape_path=scrape_path
+ ):
+ return False
logger.info("OTEL: FastAPI instrumentation active (service=%s)", settings.OTEL_SERVICE_NAME)
+ return True
+
+
+def _add_metrics_route(app: FastAPI, path: str) -> None:
+ """Serve the Prometheus registry at ``path``. Public like /ping: scrapers send no tokens,
+ so keep it on the private network."""
+ from prometheus_client import CONTENT_TYPE_LATEST, REGISTRY, generate_latest
+
+ def scrape() -> Response:
+ return Response(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
+
+ app.add_api_route(path, scrape, methods=["GET"], include_in_schema=False)
+ app.state.scrape_route = app.routes[-1]
+
+
+def _setup_metrics(app: FastAPI, graph_config: GraphConfig | None):
+ """Start the ``metrics`` block (when on) and return the MeterProvider, else None."""
+ metrics_cfg = graph_config.metrics if graph_config is not None else None
+ if metrics_cfg is None or not metrics_cfg.enabled:
+ return None
+ provider = setup_metrics(metrics_cfg, role="api")
+ if provider is None:
+ return None
+ app.state.meter_provider = provider
+ if metrics_cfg.scrape_path:
+ try:
+ _add_metrics_route(app, metrics_cfg.scrape_path)
+ except ImportError:
+ logger.warning("Metrics scrape route skipped: install '10xgraph-api[metrics]'")
+ return provider
def setup_middleware(
@@ -501,10 +591,19 @@ def setup_middleware(
if StaleStateError is not None:
app.add_exception_handler(StaleStateError, _stale_state_handler)
+ meter_provider = _setup_metrics(app, graph_config)
+ scrape_path = graph_config.metrics.scrape_path if graph_config is not None else None
+
+ instrumented = False
if settings.OTEL_ENABLED:
- _setup_otel(app, settings)
+ instrumented = _setup_otel(app, settings, meter_provider, scrape_path)
if container is not None:
_attach_otel_publisher(container, settings, graph_config)
+ # HTTP metrics once: here unless the legacy path instrumented (it can fail, e.g. no endpoint).
+ if meter_provider is not None and not instrumented:
+ _instrument_fastapi(
+ app, tracing=False, meter_provider=meter_provider, scrape_path=scrape_path
+ )
# Declarative Logfire/LangSmith wiring from 10xgraph.json (before compile).
_setup_observability(container, graph_config)
@@ -539,6 +638,11 @@ def setup_middleware(
if graph_config is not None:
rate_limit_config = graph_config.rate_limit
if rate_limit_config is not None:
+ if scrape_path: # scrapes come every few seconds from one address
+ rate_limit_config = replace(
+ rate_limit_config,
+ exclude_paths=(*rate_limit_config.exclude_paths, scrape_path),
+ )
# The counters live in the cache store, bound in the container at startup.
app.add_middleware(
RateLimitMiddleware,
@@ -558,6 +662,7 @@ def setup_middleware(
app.add_middleware(
HealthCheckAwareTrustedHostMiddleware,
+ open_paths=(scrape_path,) if scrape_path else (),
allowed_hosts=[h.strip() for h in settings.ALLOWED_HOST.split(",") if h.strip()],
)
diff --git a/tenxgraph_api/src/app/main.py b/tenxgraph_api/src/app/main.py
index d26441c..a74d052 100644
--- a/tenxgraph_api/src/app/main.py
+++ b/tenxgraph_api/src/app/main.py
@@ -17,8 +17,16 @@
init_logger,
setup_middleware,
)
-from tenxgraph_api.src.app.core.auth.route_guard import assert_all_routes_protected
+from tenxgraph_api.src.app.core.auth.route_guard import (
+ DEFAULT_PUBLIC_PATHS,
+ assert_all_routes_protected,
+)
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig, default_config_path
+from tenxgraph_api.src.app.core.config.metrics import (
+ check_scrape_route,
+ flush_telemetry,
+ native_telemetry_off,
+)
from tenxgraph_api.src.app.loader import attach_all_modules, load_container
from tenxgraph_api.src.app.routers import init_routes
@@ -91,7 +99,11 @@ async def _prepare_checkpointer() -> None:
missing permission shows in the boot log instead of on the first request. Best effort:
the checkpointer also creates its schema on its first query, so a failure here only logs.
"""
- checkpointer = container.try_get("checkpointer") or container.try_get("BaseCheckpointer")
+ from tenxgraph_api.src.app.routers.ping.readiness import readiness_targets
+
+ # The graph binds its checkpointer under the BaseCheckpointer class (never a string
+ # key); the same lookup the startup check and /health/ready use.
+ checkpointer = readiness_targets(container)[0]
asetup = getattr(checkpointer, "asetup", None)
if not callable(asetup):
return
@@ -256,6 +268,9 @@ async def lifespan(app: FastAPI):
except Exception as exc:
logger.debug("Auth close failed: %s", exc)
+ # Last, so a slow collector delays nothing else: the final metrics push and AI spans.
+ await flush_telemetry(getattr(app.state, "meter_provider", None))
+
app = FastAPI(
title=settings.APP_NAME,
@@ -268,6 +283,7 @@ async def lifespan(app: FastAPI):
openapi_url="/openapi.json" if settings.DOCS_PATH or settings.REDOCS_PATH else None,
lifespan=lifespan,
root_path=settings.ROOT_PATH,
+ **native_telemetry_off(),
)
setup_middleware(app, graph_config=graph_config, container=container)
@@ -282,6 +298,7 @@ async def lifespan(app: FastAPI):
# A malformed "media" section should stop the boot, not surface on the first delete.
graph_config.media # noqa: B018
+graph_config.metrics # noqa: B018
# init routes
# The AG-UI endpoint is opt-in through 10xgraph.json; the WebSocket routes can be turned off there.
@@ -292,9 +309,16 @@ async def lifespan(app: FastAPI):
tasks=graph_config.worker is not None,
)
+# The scrape path is open to the auth guard and rate limit: no real route may share it.
+check_scrape_route(app, getattr(app.state, "scrape_route", None))
+
# Secure by construction: refuse to boot if any non-public route forgot its
# RequirePermission guard (a forgotten guard would otherwise ship an open endpoint).
-assert_all_routes_protected(app)
+# The Prometheus scrape route is public too (scrapers send no tokens).
+_scrape_path = graph_config.metrics.scrape_path
+assert_all_routes_protected(
+ app, DEFAULT_PUBLIC_PATHS | {_scrape_path} if _scrape_path else DEFAULT_PUBLIC_PATHS
+)
# instrumentator = Instrumentator().instrument(app) # Instrument first
# instrumentator.expose(app) # Then expose
diff --git a/tests/cli/test_group6_cli_templates.py b/tests/cli/test_group6_cli_templates.py
index 4df2d81..63975bd 100644
--- a/tests/cli/test_group6_cli_templates.py
+++ b/tests/cli/test_group6_cli_templates.py
@@ -8,6 +8,7 @@
from pathlib import Path
from tenxgraph_api.cli.commands.init import InitCommand
+from tenxgraph_api.cli.commands.init_context import build_config
from tenxgraph_api.cli.core.output import OutputFormatter
from tenxgraph_api.cli.templates.defaults import generate_k8s_manifest_content
@@ -29,29 +30,29 @@ def _context(auth: str, cache_store: str = "redis", **flags) -> dict:
def test_production_rate_limit_is_per_user_when_auth_is_on() -> None:
for auth in ("jwt", "custom"):
- config = _cmd()._build_config(_context(auth), True)
+ config = build_config(_context(auth), True)
assert config["cache_store"]["rate_limit"]["by"] == "user"
- config = _cmd()._build_config(_context("none"), True)
+ config = build_config(_context("none"), True)
assert config["cache_store"]["rate_limit"]["by"] == "ip"
def test_production_cache_store_follows_the_answers() -> None:
- config = _cmd()._build_config(_context("jwt"), True)
+ config = build_config(_context("jwt"), True)
assert config["cache_store"]["type"] == "redis"
assert "url" not in config["cache_store"]
assert config["cache_store"]["token_revocation"] is True
- memory = _cmd()._build_config(_context("jwt", "memory"), True)["cache_store"]
+ memory = build_config(_context("jwt", "memory"), True)["cache_store"]
assert memory["type"] == "memory"
assert "url" not in memory
assert memory["rate_limit"]["enabled"] is True
assert memory["token_revocation"] is True
- assert "token_revocation" not in _cmd()._build_config(_context("none"), True)["cache_store"]
- off = _cmd()._build_config(_context("jwt", rate_limit=False, token_revocation=False), True)
+ assert "token_revocation" not in build_config(_context("none"), True)["cache_store"]
+ off = build_config(_context("jwt", rate_limit=False, token_revocation=False), True)
assert off["cache_store"] == {"type": "redis"}
for store in ("redis", "memory"):
- config = _cmd()._build_config(_context("jwt", store), True)
+ config = build_config(_context("jwt", store), True)
assert not {"redis", "rate_limit", "token_revocation"} & set(config)
@@ -90,7 +91,7 @@ def test_static_templates_load_through_graph_config(monkeypatch) -> None:
def test_generated_exclude_paths_use_the_real_redoc_path() -> None:
- config = _cmd()._build_config(_context("jwt"), True)
+ config = build_config(_context("jwt"), True)
assert "/redocs" in config["cache_store"]["rate_limit"]["exclude_paths"]
diff --git a/tests/cli/test_init_full.py b/tests/cli/test_init_full.py
new file mode 100644
index 0000000..d10f544
--- /dev/null
+++ b/tests/cli/test_init_full.py
@@ -0,0 +1,241 @@
+"""`10xgraph init` Full mode: presets, template blocks, flags."""
+
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+
+import pytest
+
+from tenxgraph_api.cli.commands.init import InitCommand
+from tenxgraph_api.cli.commands.init_context import (
+ build_config,
+ build_context,
+ strip_blocks,
+ template_flags,
+ template_vars,
+)
+from tenxgraph_api.cli.core.output import OutputFormatter
+from tenxgraph_api.cli.exceptions import FileOperationError, ValidationError
+
+
+def _full(**options) -> dict:
+ return build_context(path=".", agent_name="demo", mode="full", **options)
+
+
+def _init(path: Path, **options) -> tuple[int, str]:
+ stream = io.StringIO()
+ code = InitCommand(OutputFormatter(stream=stream)).execute(
+ path=str(path), agent_name="Demo", non_interactive=True, **options
+ )
+ return code, stream.getvalue()
+
+
+# --- storage presets -----------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("storage", "cache", "store_key", "core_extras"),
+ [
+ ("memory", "memory", False, "google-genai"),
+ ("sqlite", "memory", False, "google-genai,sqlite_checkpoint"),
+ ("postgres", "redis", False, "google-genai,pg_checkpoint"),
+ ("postgres-memory", "redis", True, "google-genai,pg_checkpoint,pgvector"),
+ ],
+)
+def test_storage_preset(storage: str, cache: str, store_key: bool, core_extras: str) -> None:
+ context = _full(storage=storage)
+ assert context["storage"] == storage
+ assert context["cache_store"] == cache
+ config = build_config(context, True)
+ assert config["cache_store"]["type"] == cache
+ assert ("store" in config) is store_key
+ if store_key:
+ assert config["store"] == "graph.agent:store"
+ assert template_vars(context)["core_requirement"] == f"10xgraph[{core_extras}]"
+
+
+def test_cache_store_flag_overrides_the_preset() -> None:
+ assert _full(storage="postgres", cache_store="memory")["cache_store"] == "memory"
+
+
+def test_template_flags_follow_storage_and_provider() -> None:
+ flags = template_flags(_full(storage="postgres-memory", provider="anthropic"))
+ assert flags["postgres"] and flags["redis"] and flags["store"]
+ assert flags["embed_openai"] and flags["embed_key"] and not flags["embed_google"]
+ assert template_flags(_full(provider="google", storage="postgres-memory"))["embed_google"]
+ flags = template_flags(_full(storage="memory"))
+ assert flags["inmem"] and not flags["redis"] and not flags["store"]
+ assert template_flags(_full(storage="sqlite"))["sqlite"]
+
+
+# --- security presets ----------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("auth", "rate_limit", "revocation", "by"),
+ [("jwt", True, True, "user"), ("none", True, False, "ip"), ("custom", True, True, "user")],
+)
+def test_security_preset(auth: str, rate_limit: bool, revocation: bool, by: str) -> None:
+ context = _full(auth=auth)
+ assert (context["auth"], context["rate_limit"], context["token_revocation"]) == (
+ auth,
+ rate_limit,
+ revocation,
+ )
+ assert context["rl_by"] == by
+ store = build_config(context, True)["cache_store"]
+ assert store["rate_limit"]["by"] == by
+ assert ("token_revocation" in store) is revocation
+
+
+def test_full_defaults() -> None:
+ context = _full()
+ assert (context["storage"], context["auth"], context["cache_store"]) == (
+ "memory",
+ "jwt",
+ "memory",
+ )
+ assert (context["evals"], context["tests"]) == (True, True)
+ assert not any(context[k] for k in ("websocket", "ag_ui", "otel"))
+
+
+# --- extras --------------------------------------------------------------------------
+
+
+def test_extras_flags_skip_template_dirs_and_set_config(tmp_path: Path) -> None:
+ command = InitCommand(OutputFormatter(stream=io.StringIO()))
+ off, on = tmp_path / "off", tmp_path / "on"
+ assert command.execute(path=str(off), mode="full", evals=False, tests=False, yes=True) == 0
+ assert not (off / "evals").exists() and not (off / "tests").exists()
+ assert (off / "graph" / "agent.py").exists()
+
+ assert (
+ command.execute(path=str(on), mode="full", websocket=True, ag_ui=True, otel=True, yes=True)
+ == 0
+ )
+ assert (on / "evals").is_dir() and (on / "tests").is_dir()
+ config = json.loads((on / "10xgraph.json").read_text())
+ assert config["websocket"] == {"enabled": True}
+ assert config["ag_ui"]["enabled"] is True
+ api = template_vars(_full(websocket=True, ag_ui=True, otel=True))["api_requirement"]
+ assert api == "10xgraph-api[jwt,websocket,ag-ui,otel]"
+
+
+# --- strip_blocks --------------------------------------------------------------------
+
+
+def test_strip_blocks_keeps_true_and_drops_false() -> None:
+ text = "a\n##[IF_JWT]##\nb\n##[/IF_JWT]##\n##[IF_REDIS]##\nc\n##[/IF_REDIS]##\nd\n"
+ assert strip_blocks(text, {"jwt": True, "redis": False}) == "a\nb\nd\n"
+
+
+def test_strip_blocks_handles_comment_prefix_and_nesting() -> None:
+ text = "# ##[IF_A]##\nx\n# ##[IF_B]##\ny\n# ##[/IF_B]##\n# ##[/IF_A]##\nz\n"
+ assert strip_blocks(text, {"a": True, "b": True}) == "x\ny\nz\n"
+ assert strip_blocks(text, {"a": True, "b": False}) == "x\nz\n"
+ assert strip_blocks(text, {"a": False, "b": True}) == "z\n"
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "##[IF_TYPO]##\nx\n##[/IF_TYPO]##\n",
+ "##[IF_JWT]##\nx\n",
+ "x\n##[/IF_JWT]##\n",
+ "##[IF_JWT]##\n##[IF_REDIS]##\n##[/IF_JWT]##\n##[/IF_REDIS]##\n",
+ "value ##[IF_JWT]## inline ##[/IF_JWT]##\n",
+ ],
+)
+def test_strip_blocks_rejects_bad_markers(text: str) -> None:
+ with pytest.raises(FileOperationError):
+ strip_blocks(text, {"jwt": True, "redis": True})
+
+
+# --- flags and validation ------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("options", "choices"),
+ [
+ ({"mode": "huge"}, "quick, full"),
+ ({"storage": "mongo"}, "memory, sqlite, postgres, postgres-memory"),
+ ({"auth": "basic"}, "none, jwt, custom"),
+ ({"cache_store": "valkey"}, "memory, redis"),
+ ({"provider": "mistral"}, "google, openai, anthropic"),
+ ],
+)
+def test_invalid_values_list_the_choices(options: dict, choices: str) -> None:
+ with pytest.raises(ValidationError) as exc:
+ build_context(path=".", agent_name="demo", **{"mode": "full", **options})
+ assert choices in exc.value.message
+
+
+def test_invalid_template_and_conflicting_mode() -> None:
+ with pytest.raises(ValidationError):
+ build_context(path=".", agent_name="demo", template="enterprise")
+ with pytest.raises(ValidationError):
+ build_context(path=".", agent_name="demo", mode="quick", template="production")
+
+
+def test_quick_rejects_full_only_options() -> None:
+ for option in ({"storage": "sqlite"}, {"evals": True}, {"ag_ui": True}, {"otel": True}):
+ with pytest.raises(ValidationError, match="--mode full"):
+ build_context(path=".", agent_name="demo", **option)
+
+
+def test_template_aliases() -> None:
+ assert build_context(path=".", agent_name="d", template="production")["setup_type"] == (
+ "production"
+ )
+ assert build_context(path=".", agent_name="d", template="quick-start")["setup_type"] == (
+ "quick_start"
+ )
+
+
+def test_yes_is_quick_unless_mode_full(tmp_path: Path) -> None:
+ command = InitCommand(OutputFormatter(stream=io.StringIO()))
+ quick, full = tmp_path / "quick", tmp_path / "full"
+ assert command.execute(path=str(quick), yes=True) == 0
+ assert command.execute(path=str(full), mode="full", yes=True) == 0
+ assert not (quick / "evals").exists() and (full / "evals").is_dir()
+ assert json.loads((quick / "10xgraph.json").read_text())["auth"] is None
+ assert json.loads((full / "10xgraph.json").read_text())["auth"] == {"method": "jwt"}
+
+
+def test_invalid_storage_fails_the_command(tmp_path: Path) -> None:
+ code, text = _init(tmp_path / "x", mode="full", storage="mongo")
+ assert code != 0
+ assert "memory, sqlite, postgres, postgres-memory" in text
+ assert not (tmp_path / "x").exists()
+
+
+def test_dry_run_lists_files_by_extras(tmp_path: Path) -> None:
+ code, text = _init(tmp_path / "d", mode="full", evals=False, tests=True, dry_run=True)
+ assert code == 0
+ assert "Files that would be created" in text
+ assert "evals/" not in text and "tests/" in text
+ assert not (tmp_path / "d").exists()
+
+
+def test_anthropic_store_adds_openai_extra_for_embeddings() -> None:
+ context = _full(storage="postgres-memory", provider="anthropic")
+ assert (
+ template_vars(context)["core_requirement"]
+ == "10xgraph[anthropic,pg_checkpoint,pgvector,openai]"
+ )
+ openai = _full(storage="postgres-memory", provider="openai")
+ assert template_vars(openai)["core_requirement"] == "10xgraph[openai,pg_checkpoint,pgvector]"
+
+
+@pytest.mark.parametrize(("tests", "evals"), [(True, True), (True, False), (False, True)])
+def test_config_test_and_evaluation_sections_follow_flags(tests: bool, evals: bool) -> None:
+ config = build_config(_full(tests=tests, evals=evals), True)
+ assert ("test" in config) is tests
+ assert ("evaluation" in config) is evals
+ if tests:
+ assert config["test"] == {"path": "tests", "coverage": True, "coverage_threshold": 70}
+ if evals:
+ assert config["evaluation"]["threshold"] == 0.75
+ assert config["evaluation"]["directory"] == "evals"
diff --git a/tests/cli/test_init_non_interactive.py b/tests/cli/test_init_non_interactive.py
index 2e5d1a6..fb9f0b8 100644
--- a/tests/cli/test_init_non_interactive.py
+++ b/tests/cli/test_init_non_interactive.py
@@ -77,6 +77,7 @@ def test_production_dependencies_follow_the_answers(tmp_path: Path) -> None:
agent_name="Secure Agent",
template="production",
auth="jwt",
+ storage="postgres",
provider="openai",
non_interactive=True,
)
@@ -109,6 +110,7 @@ def test_production_without_jwt_installs_redis_but_not_jwt(tmp_path: Path) -> No
agent_name="Open Agent",
template="production",
auth="custom",
+ storage="postgres",
cache_store="memory",
provider="anthropic",
non_interactive=True,
@@ -143,7 +145,7 @@ def test_websocket_off_by_default_and_not_installed(tmp_path: Path) -> None:
path=str(target), template="production", provider="openai", non_interactive=True
)
assert result == 0
- assert _dependencies(target) == ["10xgraph[openai,pg_checkpoint]", "10xgraph-api[jwt,redis]"]
+ assert _dependencies(target) == ["10xgraph[openai]", "10xgraph-api[jwt]"]
config = json.loads((target / "10xgraph.json").read_text())
assert config["websocket"] == {"enabled": False}
@@ -162,10 +164,7 @@ def test_websocket_on_adds_the_extra_and_enables_the_routes(tmp_path: Path) -> N
non_interactive=True,
)
assert result == 0
- assert _dependencies(target) == [
- "10xgraph[anthropic,pg_checkpoint]",
- "10xgraph-api[jwt,redis,websocket]",
- ]
+ assert _dependencies(target) == ["10xgraph[anthropic]", "10xgraph-api[jwt,websocket]"]
config = json.loads((target / "10xgraph.json").read_text())
assert config["websocket"] == {"enabled": True}
@@ -273,8 +272,10 @@ def _written(tmp_path: Path, **kwargs) -> dict:
def test_cache_store_choices(tmp_path: Path) -> None:
- assert _written(tmp_path)["cache_store"]["type"] == "redis"
- assert _written(tmp_path / "m", cache_store="memory")["cache_store"]["type"] == "memory"
+ assert _written(tmp_path)["cache_store"]["type"] == "memory"
+ assert _written(tmp_path / "p", storage="postgres")["cache_store"]["type"] == "redis"
+ override = _written(tmp_path / "m", storage="postgres", cache_store="memory")
+ assert override["cache_store"]["type"] == "memory"
assert "url" not in _written(tmp_path / "r", cache_store="redis")["cache_store"]
@@ -287,12 +288,12 @@ def test_invalid_cache_store_lists_the_choices(tmp_path: Path) -> None:
non_interactive=True,
)
assert result != 0
- assert "memory or redis" in stream.getvalue()
+ assert "memory, redis" in stream.getvalue()
def test_rate_limit_off_and_token_revocation_off(tmp_path: Path) -> None:
store = _written(tmp_path / "a", rate_limit=False, token_revocation=False)["cache_store"]
- assert store == {"type": "redis"}
+ assert store == {"type": "memory"}
store = _written(tmp_path / "b", token_revocation=False)["cache_store"]
assert "token_revocation" not in store and store["rate_limit"]["enabled"] is True
@@ -302,22 +303,27 @@ def test_token_revocation_is_ignored_without_auth(tmp_path: Path) -> None:
assert "token_revocation" not in store
-def test_quick_start_ignores_cache_flags(tmp_path: Path) -> None:
- import json
-
- target = tmp_path / "q"
- result = InitCommand(OutputFormatter(stream=io.StringIO())).execute(
- path=str(target), cache_store="redis", rate_limit=True, non_interactive=True
+def test_quick_start_rejects_full_only_flags(tmp_path: Path) -> None:
+ stream = io.StringIO()
+ result = InitCommand(OutputFormatter(stream=stream)).execute(
+ path=str(tmp_path / "q"), cache_store="redis", rate_limit=True, non_interactive=True
)
- assert result == 0
- assert "cache_store" not in json.loads((target / "10xgraph.json").read_text())
+ assert result != 0
+ assert "--mode full" in stream.getvalue()
-def test_next_steps_point_to_config_for_every_template(tmp_path: Path) -> None:
- for template in ("quick-start", "production"):
+def test_next_steps_point_to_config_for_every_mode(tmp_path: Path) -> None:
+ texts = {}
+ for mode in ("quick", "full"):
stream = io.StringIO()
InitCommand(OutputFormatter(stream=stream)).execute(
- path=str(tmp_path / template), template=template, non_interactive=True
+ path=str(tmp_path / mode), mode=mode, non_interactive=True
)
- text = " ".join(stream.getvalue().split())
- assert "Run 10xgraph config for full control" in text
+ texts[mode] = " ".join(stream.getvalue().split())
+ assert "10xgraph config for more settings" in texts["quick"]
+ assert "Before production" not in texts["quick"]
+ assert "10xgraph config to review auth" in texts["full"]
+ assert "Before production" in texts["full"]
+ assert "10xgraph build" in texts["full"]
+ assert "JWT_SECRET_KEY" in texts["full"]
+ assert "10xgraph skills" in texts["full"]
diff --git a/tests/cli/test_init_prod.py b/tests/cli/test_init_prod.py
index 1dba51f..1f1d285 100644
--- a/tests/cli/test_init_prod.py
+++ b/tests/cli/test_init_prod.py
@@ -6,6 +6,7 @@
from pathlib import Path
from tenxgraph_api.cli.commands.init import InitCommand
+from tenxgraph_api.cli.commands.init_context import build_config
from tenxgraph_api.cli.core.output import OutputFormatter
from tenxgraph_api.src.app.core.auth.auth_config import check_auth_config
@@ -80,43 +81,45 @@ def test_init_prod_skips_binary_template_artifacts() -> None:
)
-def test_production_template_defaults_to_recommended_setup(tmp_path: Path) -> None:
- """With no answers, production scaffolds JWT, ownership, Postgres + Redis and rate limits."""
+def test_full_mode_defaults_to_memory_storage_and_jwt(tmp_path: Path) -> None:
+ """With no answers, Full scaffolds JWT, rate limits and in-memory storage."""
import json
import tomllib
- target = tmp_path / "default-prod"
+ target = tmp_path / "default-full"
command = InitCommand(OutputFormatter(stream=io.StringIO()))
- result = command.execute(
- path=str(target), agent_name="Default Prod", template="production", non_interactive=True
- )
+ result = command.execute(path=str(target), agent_name="Default Full", mode="full", yes=True)
assert result == 0
config = json.loads((target / "10xgraph.json").read_text())
assert config["auth"] == {"method": "jwt"}
assert "authorization" not in config
+ assert "store" not in config
assert config["ag_ui"] == {"enabled": False, "allow_client_tools": False}
assert config["remote_tools"] == []
assert check_auth_config(config, production=True) == []
- assert config["cache_store"]["type"] == "redis"
- assert "url" not in config["cache_store"]
+ assert config["cache_store"]["type"] == "memory"
assert config["cache_store"]["rate_limit"]["enabled"] is True
assert config["cache_store"]["token_revocation"] is True
+ assert "InMemoryCheckpointer()" in (target / "graph" / "agent.py").read_text()
+ assert (target / "evals").is_dir() and (target / "tests").is_dir()
+
+ dependencies = tomllib.loads((target / "pyproject.toml").read_text())["project"]["dependencies"]
+ assert dependencies == ["10xgraph[google-genai]", "10xgraph-api[jwt]"]
- agent_py = (target / "graph" / "agent.py").read_text()
- assert "PgCheckpointer(" in agent_py
- memory_lines = [line for line in agent_py.splitlines() if "InMemoryCheckpointer" in line]
- assert memory_lines
- assert all(line.lstrip().startswith("#") for line in memory_lines)
+def test_full_postgres_scaffolds_pg_checkpointer_and_env(tmp_path: Path) -> None:
+ target = tmp_path / "pg"
+ command = InitCommand(OutputFormatter(stream=io.StringIO()))
+ result = command.execute(
+ path=str(target), agent_name="Pg", mode="full", storage="postgres", non_interactive=True
+ )
+ assert result == 0
+ assert "PgCheckpointer(" in (target / "graph" / "agent.py").read_text()
env_example = (target / ".env.example").read_text()
assert "DATABASE_URL=" in env_example
assert "REDIS_URL=" in env_example
- dependencies = tomllib.loads((target / "pyproject.toml").read_text())["project"]["dependencies"]
- assert any("pg_checkpoint" in dep for dep in dependencies)
- assert any(dep.startswith("10xgraph-api[") and "redis" in dep for dep in dependencies)
-
class _Scripted:
"""Prompt double: answers by message prefix, else accepts the default; records asks."""
@@ -142,10 +145,11 @@ def confirm(self, message, default=False, **_kwargs):
return self._answer(message, default)
def select(self, message, _choices, default=None, **_kwargs):
- if message == "Which project template?":
- return "production"
return self._answer(message, default)
+ def checkbox(self, message, choices, **_kwargs):
+ return self._answer(message, [c.value for c in choices if c.checked])
+
def _run_prompts(monkeypatch, answers=None):
output = io.StringIO()
@@ -155,45 +159,47 @@ def _run_prompts(monkeypatch, answers=None):
return InitCommand(output=formatter)._prompt_user(), scripted, output.getvalue()
-def test_interactive_production_asks_only_the_basics(monkeypatch) -> None:
- """Name, template and provider are the only questions; Production gets the full setup."""
- context, scripted, text = _run_prompts(monkeypatch)
+_FULL = {"Which setup?": "full"}
+
+def test_interactive_quick_asks_three_questions(monkeypatch) -> None:
+ context, scripted, _ = _run_prompts(monkeypatch)
+ assert scripted.asked == ["Which setup?", "What is your agent name?", "Which AI provider?"]
+ assert context is not None and context["setup_type"] == "quick_start"
+ assert context["storage"] is None and context["cache_store"] is None
+ assert context["rate_limit"] is False and context["auth"] == "none"
+ assert build_config(context, False).keys() >= {"agent"}
+ assert "cache_store" not in build_config(context, False)
+
+
+def test_interactive_full_asks_four_more_questions(monkeypatch) -> None:
+ context, scripted, _ = _run_prompts(monkeypatch, _FULL)
assert scripted.asked == [
+ "Which setup?",
"What is your agent name?",
"Which AI provider?",
+ "Where should state be stored?",
+ "How should the API be secured?",
+ "Background tasks?",
+ "Which extras?",
]
assert context is not None
assert context["setup_type"] == "production"
+ assert context["storage"] == "memory"
assert context["auth"] == "jwt"
- assert context["cache_store"] == "redis"
+ assert context["cache_store"] == "memory"
assert context["rate_limit"] is True
assert context["token_revocation"] is True
assert context["id_generator"] == "uuid"
- assert context["websocket"] is False
- assert "cannot reach" not in text
+ assert (context["evals"], context["tests"]) == (True, True)
+ assert (context["websocket"], context["ag_ui"], context["otel"]) == (False, False, False)
-def test_interactive_quick_start_has_no_cache_store(monkeypatch) -> None:
- class _Quick(_Scripted):
- def select(self, message, _choices, default=None, **_kwargs):
- return "quick_start" if message == "Which project template?" else default
-
- output = io.StringIO()
- formatter = OutputFormatter(stream=output)
- monkeypatch.setattr(formatter, "prompts", lambda: _Quick())
- context = InitCommand(output=formatter)._prompt_user()
- assert context is not None and context["setup_type"] == "quick_start"
- assert context["cache_store"] is None and context["rate_limit"] is False
- assert InitCommand(output=formatter)._build_config(context, False).keys() >= {"agent"}
- assert "cache_store" not in InitCommand(output=formatter)._build_config(context, False)
-
-
-def test_interactive_production_writes_the_recommended_config(monkeypatch, tmp_path) -> None:
+def test_interactive_full_writes_the_recommended_config(monkeypatch) -> None:
import json
- context, _, _ = _run_prompts(monkeypatch)
- config = InitCommand(output=SilentOutput())._build_config(context, True)
+ context, _, _ = _run_prompts(monkeypatch, _FULL)
+ config = build_config(context, True)
assert config["auth"] == {"method": "jwt"}
assert "authorization" not in config
assert config["ag_ui"] == {"enabled": False, "allow_client_tools": False}
@@ -201,7 +207,7 @@ def test_interactive_production_writes_the_recommended_config(monkeypatch, tmp_p
assert check_auth_config(config, production=True) == []
assert config["id_generator"] == "uuid"
assert config["cache_store"] == {
- "type": "redis",
+ "type": "memory",
"rate_limit": {
"enabled": True,
"requests": 100,
@@ -215,23 +221,25 @@ def test_interactive_production_writes_the_recommended_config(monkeypatch, tmp_p
}
template = Path(__file__).parents[2] / "tenxgraph_api/cli/templates/prod/10xgraph.json"
static = json.loads(template.read_text(encoding="utf-8"))
- for key in (
- "auth",
- "ag_ui",
- "remote_tools",
- "id_generator",
- "cache_store",
- "thread_name_generator",
- ):
+ for key in ("auth", "ag_ui", "remote_tools", "id_generator", "thread_name_generator"):
assert static[key] == config[key], key
-def test_summary_shows_the_production_choices(monkeypatch) -> None:
- context, _, _ = _run_prompts(monkeypatch)
+def test_summary_shows_the_full_choices(monkeypatch) -> None:
+ context, _, _ = _run_prompts(monkeypatch, _FULL)
stream = io.StringIO()
InitCommand(output=OutputFormatter(stream=stream))._print_summary(context)
text = stream.getvalue()
- for label in ("Auth", "Cache store", "Rate limit", "Token revocation", "ID generator"):
+ for label in (
+ "Storage",
+ "Auth",
+ "Cache store",
+ "Rate limit",
+ "Token revocation",
+ "ID generator",
+ "Evaluations",
+ "Tests",
+ ):
assert label in text
@@ -249,7 +257,11 @@ def test_production_graph_builds_a_pg_checkpointer_without_services(tmp_path: Pa
command = InitCommand(OutputFormatter(stream=io.StringIO()))
assert (
command.execute(
- path=str(target), agent_name="Pg Agent", template="production", non_interactive=True
+ path=str(target),
+ agent_name="Pg Agent",
+ mode="full",
+ storage="postgres",
+ non_interactive=True,
)
== 0
)
diff --git a/tests/cli/test_init_worker.py b/tests/cli/test_init_worker.py
new file mode 100644
index 0000000..9eab5fe
--- /dev/null
+++ b/tests/cli/test_init_worker.py
@@ -0,0 +1,183 @@
+"""`10xgraph init` Full mode background worker: question, flag, config, template files."""
+
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+
+import pytest
+
+from tenxgraph_api.cli.commands.init import InitCommand
+from tenxgraph_api.cli.commands.init_context import (
+ build_config,
+ build_context,
+ template_flags,
+ template_vars,
+)
+from tenxgraph_api.cli.core.output import OutputFormatter
+from tenxgraph_api.cli.exceptions import ValidationError
+from tenxgraph_api.jobs.config import WorkerConfig
+
+from .test_init_prod import _FULL, _run_prompts
+
+
+BROKERS = {
+ "redis": ("redis_stream", "${REDIS_URL}", "jobs"),
+ "rabbitmq": ("rabbitmq", "${BROKER_URL}", "jobs-rabbitmq"),
+ "kafka": ("kafka", "${BROKER_URL}", "jobs-kafka"),
+}
+
+
+def _full(**options) -> dict:
+ return build_context(path=".", agent_name="demo", mode="full", **options)
+
+
+def _init(path: Path, **options) -> int:
+ return InitCommand(OutputFormatter(stream=io.StringIO())).execute(
+ path=str(path), agent_name="Demo", non_interactive=True, mode="full", **options
+ )
+
+
+# --- prompts -------------------------------------------------------------------------
+
+
+def test_full_prompt_asks_worker_after_auth_and_defaults_to_none(monkeypatch) -> None:
+ context, scripted, _ = _run_prompts(monkeypatch, _FULL)
+ assert scripted.asked[-3:] == [
+ "How should the API be secured?",
+ "Background tasks?",
+ "Which extras?",
+ ]
+ assert context is not None and context["worker"] == "none"
+
+
+def test_full_prompt_picks_a_worker(monkeypatch) -> None:
+ context, _, _ = _run_prompts(monkeypatch, {**_FULL, "Background tasks?": "rabbitmq"})
+ assert context is not None
+ assert context["worker"] == "rabbitmq" and context["cache_store"] == "redis"
+
+
+def test_cancel_at_the_worker_question_returns_none(monkeypatch) -> None:
+ context, _, _ = _run_prompts(monkeypatch, {**_FULL, "Background tasks?": None})
+ assert context is None
+
+
+def test_quick_prompt_does_not_ask_about_workers(monkeypatch) -> None:
+ context, scripted, _ = _run_prompts(monkeypatch)
+ assert "Background tasks?" not in scripted.asked
+ assert context is not None and context["worker"] == "none"
+
+
+# --- flag ----------------------------------------------------------------------------
+
+
+def test_worker_flag_in_quick_mode_is_a_full_only_error() -> None:
+ with pytest.raises(ValidationError, match="--worker requires --mode full"):
+ build_context(path=".", agent_name="demo", mode="quick", worker="redis")
+ assert (
+ build_context(path=".", agent_name="demo", mode="quick", worker="none")["worker"] == "none"
+ )
+
+
+def test_invalid_worker_is_rejected() -> None:
+ with pytest.raises(ValidationError, match="Invalid worker"):
+ _full(worker="sqs")
+
+
+def test_worker_with_memory_cache_store_is_an_error() -> None:
+ with pytest.raises(ValidationError, match="cache-store redis"):
+ _full(worker="redis", cache_store="memory")
+
+
+def test_worker_forces_the_redis_cache_store() -> None:
+ context = _full(worker="kafka", storage="memory")
+ assert context["cache_store"] == "redis"
+ assert template_flags(context)["redis"]
+
+
+# --- config, flags, extras -----------------------------------------------------------
+
+
+@pytest.mark.parametrize("worker", list(BROKERS))
+def test_build_config_has_the_worker_section(worker: str) -> None:
+ broker_type, url, _ = BROKERS[worker]
+ config = build_config(_full(worker=worker), True)
+ assert config["worker"] == {
+ "broker": {"type": broker_type, "url": url},
+ "tasks": ["worker.tasks"],
+ }
+ WorkerConfig.model_validate(config["worker"])
+
+
+def test_no_worker_section_for_none() -> None:
+ assert "worker" not in build_config(_full(), True)
+ assert "worker" not in build_config(_full(worker="none"), True)
+
+
+@pytest.mark.parametrize("worker", list(BROKERS))
+def test_pyproject_extra_and_flags(worker: str) -> None:
+ context = _full(worker=worker)
+ flags = template_flags(context)
+ assert flags["worker"] and flags["broker_url"] is (worker != "redis")
+ assert f"{BROKERS[worker][2]}" in template_vars(context)["api_requirement"].split("[")[1]
+
+
+def test_no_worker_flags_by_default() -> None:
+ flags = template_flags(_full())
+ assert not flags["worker"] and not flags["broker_url"]
+ assert "jobs" not in template_vars(_full())["api_requirement"]
+
+
+# --- scaffold ------------------------------------------------------------------------
+
+
+def test_worker_files_only_when_picked(tmp_path: Path) -> None:
+ assert _init(tmp_path / "off") == 0
+ assert not (tmp_path / "off" / "worker").exists()
+ assert _init(tmp_path / "on", worker="redis") == 0
+ assert (tmp_path / "on" / "worker" / "__init__.py").is_file()
+ assert (tmp_path / "on" / "worker" / "tasks.py").is_file()
+
+
+def test_dry_run_lists_worker_files(tmp_path: Path) -> None:
+ stream = io.StringIO()
+ code = InitCommand(OutputFormatter(stream=stream)).execute(
+ path=str(tmp_path / "p"),
+ agent_name="Demo",
+ non_interactive=True,
+ mode="full",
+ worker="redis",
+ dry_run=True,
+ )
+ assert code == 0 and "tasks.py" in stream.getvalue()
+ assert not (tmp_path / "p").exists()
+
+
+@pytest.mark.parametrize(
+ ("worker", "expected"),
+ [
+ ("redis", None),
+ ("rabbitmq", 'BROKER_URL="amqp://guest:guest@localhost:5672/"'),
+ ("kafka", 'BROKER_URL="localhost:9092"'),
+ ],
+)
+def test_env_example_per_broker(tmp_path: Path, worker: str, expected: str | None) -> None:
+ assert _init(tmp_path, worker=worker) == 0
+ env = (tmp_path / ".env.example").read_text()
+ assert "REDIS_URL=" in env
+ if expected is None:
+ assert "BROKER_URL" not in env
+ else:
+ assert expected in env
+
+
+def test_generated_project_files_are_valid(tmp_path: Path) -> None:
+ assert _init(tmp_path, worker="kafka") == 0
+ tasks = (tmp_path / "worker" / "tasks.py").read_text()
+ compile(tasks, "tasks.py", "exec")
+ assert "$" not in tasks
+ config = json.loads((tmp_path / "10xgraph.json").read_text())
+ worker = WorkerConfig.model_validate(config["worker"])
+ assert worker.broker.type == "kafka" and worker.tasks == ["worker.tasks"]
+ assert "jobs-kafka" in (tmp_path / "pyproject.toml").read_text()
diff --git a/tests/unit_tests/jobs/test_middleware.py b/tests/unit_tests/jobs/test_middleware.py
index b881245..0f16167 100644
--- a/tests/unit_tests/jobs/test_middleware.py
+++ b/tests/unit_tests/jobs/test_middleware.py
@@ -171,6 +171,22 @@ async def close(self): ...
def sync_close(self): ...
+@pytest.fixture(autouse=True)
+def only_calls(monkeypatch):
+ """Record the ``only`` argument of each publish_event call. The installed core may predate
+ ``only``, so the stand-in drops it before calling the real function."""
+ from tenxgraph.runtime.publisher import publish
+
+ real, seen = publish.publish_event, []
+
+ def fake(event, publisher, task_manager, only=None):
+ seen.append(only)
+ real(event, publisher=publisher, task_manager=task_manager)
+
+ monkeypatch.setattr(publish, "publish_event", fake)
+ return seen
+
+
@pytest_asyncio.fixture
async def monitored():
env = Env(InMemoryCacheStore(), MonitoringMiddleware(), TaskMiddleware())
@@ -181,7 +197,7 @@ async def monitored():
await env.broker.shutdown()
-async def test_monitoring_logs_and_publishes(monitored, caplog):
+async def test_monitoring_logs_and_publishes(monitored, caplog, only_calls):
env, manager = monitored
publisher = FakePublisher()
env.container.bind_instance(BasePublisher, publisher)
@@ -210,7 +226,6 @@ async def run(task, *args) -> list[tuple[str, str]]:
ok_events = list(publisher.events)
caplog.clear()
failed = await run(boom)
- failed_events = list(publisher.events)
failed_records = list(caplog.records)
caplog.clear()
cancelled = await run(stopped)
@@ -225,16 +240,9 @@ async def run(task, *args) -> list[tuple[str, str]]:
]
assert ok_events[2].data["duration"] >= 0
assert "return_value" not in ok_events[3].data
- assert failed == [
- ("queued", "update"),
- ("started", "start"),
- ("failed", "error"),
- ("saved", "result"),
- ]
- error = failed_events[2]
- assert error.is_error and error.data["error"] == "ValueError"
- assert error.data["message"].startswith("bad x") and len(error.data["message"]) == 500
- assert error.data["attempt"] == 1 and "duration" in error.data
+ assert only_calls and set(only_calls) == {"events"}
+ # A failure is logged and counted but not published (no "failed" event).
+ assert failed == [("queued", "update"), ("started", "start"), ("saved", "result")]
logged = [
r for r in failed_records if r.levelno == logging.ERROR and r.name == "tenxgraph_api.jobs"
]
@@ -255,7 +263,7 @@ async def run(task, *args) -> list[tuple[str, str]]:
assert any("cancelled" in r.getMessage() for r in cancelled_records)
-async def test_monitoring_counts_retries():
+async def test_monitoring_counts_retries(caplog):
env = Env(
InMemoryCacheStore(), SimpleRetryMiddleware(default_retry_count=3), MonitoringMiddleware()
)
@@ -269,11 +277,17 @@ async def test_monitoring_counts_retries():
async def flaky() -> str:
raise RuntimeError("again")
+ caplog.set_level(logging.ERROR, logger="tenxgraph_api.jobs")
handle = await kiq_with_id(flaky)
await handle.wait_result(timeout=5)
await asyncio.sleep(0.3) # retries are re-sent in the background
await manager.wait_for_all(timeout=5)
- attempts = [e.data["attempt"] for e in publisher.events if e.metadata["phase"] == "failed"]
+ assert not [e for e in publisher.events if e.is_error]
+ attempts = [
+ int(r.getMessage().split("failed on attempt ")[1].split()[0])
+ for r in caplog.records
+ if "failed on attempt" in r.getMessage()
+ ]
assert attempts == [1, 2, 3]
await env.broker.shutdown()
@@ -351,7 +365,7 @@ async def stopped() -> None:
("stopped", "queued"),
("stopped", "cancelled"),
}
- durations = [c for c in meter.calls if c[1] == "tenxgraph.task.duration_ms"]
+ durations = [c for c in meter.calls if c[1] == "tenxgraph.task.duration"]
assert len(durations) == 3
assert all(c[0] == "histogram" and c[2] >= 0 and set(c[3]) == {"task"} for c in durations)
diff --git a/tests/unit_tests/jobs/test_worker.py b/tests/unit_tests/jobs/test_worker.py
index 804cdf1..380ff00 100644
--- a/tests/unit_tests/jobs/test_worker.py
+++ b/tests/unit_tests/jobs/test_worker.py
@@ -146,3 +146,124 @@ def test_result_ttl_set_from_config(monkeypatch, tmp_path):
def _restore_worker_module():
yield
sys.modules.pop("tenxgraph_api.jobs.worker", None)
+
+
+METRICS_DATA = {
+ "agent": "a:b",
+ "worker": {"broker": {"type": "memory"}},
+ "observability": {"otlp": {"enabled": True, "use_metrics_endpoint": True}},
+ "metrics": {"enabled": True, "export": "otlp", "otlp": {"endpoint": "http://c:4318"}},
+}
+
+
+@pytest.mark.asyncio
+async def test_worker_applies_observability_and_metrics_then_flushes(monkeypatch, tmp_path):
+ from injectq import InjectQ
+
+ from tenxgraph_api.src.app.core.config import metrics as metrics_module
+ from tenxgraph_api.src.app.core.config import setup_middleware as sm
+
+ worker = load_worker(monkeypatch, tmp_path, METRICS_DATA)
+ mine = InjectQ()
+ order = []
+ provider = object()
+ roles = []
+
+ class FakeGraph:
+ async def aclose(self, **kwargs):
+ order.append("graph closed")
+
+ async def fake_attach(config, cont):
+ order.append("graph built")
+ return FakeGraph()
+
+ from tenxgraph_api.src.app import loader
+ from tenxgraph_api.src.app.core.config.settings import get_settings
+
+ monkeypatch.setattr(loader, "attach_all_modules", fake_attach)
+ monkeypatch.setattr(loader, "load_container", lambda path: mine)
+ monkeypatch.setattr(worker, "wrap_tasks", lambda b: None)
+ monkeypatch.setenv("STARTUP_CHECKS", "false")
+ get_settings.cache_clear()
+ monkeypatch.setattr(
+ metrics_module, "setup_metrics", lambda cfg, role: roles.append(role) or provider
+ )
+ monkeypatch.setattr(
+ sm, "_setup_observability", lambda c, cfg: order.append(("observability", c is mine))
+ )
+
+ async def fake_flush(p):
+ order.append(("telemetry flushed", p))
+
+ monkeypatch.setattr(metrics_module, "flush_telemetry", fake_flush)
+
+ class FakeCacheStore:
+ async def aclose(self):
+ order.append("cache store closed")
+
+ from tenxgraph_api.src.app.core.cache_store import BaseCacheStore
+
+ state = SimpleNamespace()
+ await worker.worker_startup(state)
+ mine.bind_instance(BaseCacheStore, FakeCacheStore())
+ await worker.worker_shutdown(state)
+
+ assert roles == ["worker"]
+ assert state.meter_provider is provider
+ assert order == [
+ ("observability", True),
+ "graph built",
+ "graph closed",
+ "cache store closed",
+ ("telemetry flushed", provider), # last: a slow collector cuts nothing else
+ ]
+ get_settings.cache_clear()
+
+
+@pytest.mark.asyncio
+async def test_worker_with_legacy_otel_and_metrics_installs_one_meter_provider(
+ monkeypatch, tmp_path
+):
+ from injectq import InjectQ
+
+ from tenxgraph_api.src.app.core.config import metrics as metrics_module
+ from tenxgraph_api.src.app.core.config import setup_middleware as sm
+ from tenxgraph_api.src.app.core.config.settings import get_settings
+
+ worker = load_worker(monkeypatch, tmp_path, METRICS_DATA)
+ _patch_loader(monkeypatch, container=InjectQ())
+ monkeypatch.setattr(worker, "wrap_tasks", lambda b: None)
+ monkeypatch.setenv("OTEL_ENABLED", "true")
+ get_settings.cache_clear()
+ provider = object()
+ monkeypatch.setattr(metrics_module, "setup_metrics", lambda cfg, role: provider)
+ monkeypatch.setattr(sm, "_setup_observability", lambda c, cfg: None)
+ monkeypatch.setattr(sm, "_attach_otel_publisher", lambda *a: None)
+ seen = []
+ monkeypatch.setattr(
+ sm,
+ "setup_otel_provider",
+ lambda settings, *, metrics_owned=False: seen.append(metrics_owned),
+ )
+
+ await worker.worker_startup(SimpleNamespace())
+
+ assert seen == [True]
+ get_settings.cache_clear()
+
+
+@pytest.mark.asyncio
+async def test_worker_without_metrics_block_sets_no_provider(monkeypatch, tmp_path):
+ from injectq import InjectQ
+
+ from tenxgraph_api.src.app.core.config.settings import get_settings
+
+ data = {"agent": "a:b", "worker": {"broker": {"type": "memory"}}}
+ worker = load_worker(monkeypatch, tmp_path, data)
+ _patch_loader(monkeypatch, container=InjectQ())
+ monkeypatch.setattr(worker, "wrap_tasks", lambda b: None)
+ state = SimpleNamespace()
+ await worker.worker_startup(state)
+ assert state.meter_provider is None
+ await worker.worker_shutdown(state)
+ get_settings.cache_clear()
diff --git a/tests/unit_tests/test_config_editor.py b/tests/unit_tests/test_config_editor.py
index 84f8637..cd3f584 100644
--- a/tests/unit_tests/test_config_editor.py
+++ b/tests/unit_tests/test_config_editor.py
@@ -441,6 +441,18 @@ def test_observability_checks() -> None:
assert _levels(issues, "observability") == ["warning"]
+def test_observability_phoenix_block() -> None:
+ ok = validate_config(
+ {"agent": "graph.react:app", "observability": {"phoenix": {"enabled": True}}}
+ )
+ assert _levels(ok, "observability") == []
+ bad = validate_config(
+ {"agent": "graph.react:app", "observability": {"phoenix": {"enabled": "yes"}}}
+ )
+ assert _levels(bad, "observability.phoenix.enabled") == ["error"]
+ assert "observability.phoenix.project" in _fields()
+
+
def test_observability_user_id_mode() -> None:
for mode in ("raw", "hash", "mask"):
ok = validate_config({"agent": "graph.react:app", "observability": {"user_id_mode": mode}})
@@ -871,3 +883,130 @@ def test_worker_unsupported_type_is_refused(worker: dict, path: str, message: st
issues = validate_config({"agent": "a:b", "worker": worker})
matching = [i for i in issues if i["path"] == path]
assert [i["message"] for i in matching] == [message]
+
+
+# ---------------------------------------------------------------------------
+# AI tracing destinations and metrics
+# ---------------------------------------------------------------------------
+
+_FULL_TELEMETRY = {
+ "agent": "graph.react:app",
+ "observability": {
+ "level": "full",
+ "logfire": {"enabled": True, "service_name": "svc", "send_to_logfire": False},
+ "langsmith": {"enabled": True, "project": "p", "endpoint": "https://smith.example/otel"},
+ "phoenix": {"enabled": True, "project": "p", "endpoint": "http://localhost:6006"},
+ "otlp": {"enabled": True, "use_metrics_endpoint": True},
+ },
+ "metrics": {
+ "enabled": True,
+ "export": "otlp",
+ "otlp": {"endpoint": "http://collector:4318"},
+ "latency_buckets_ms": [100, 500, 1000],
+ },
+}
+
+
+def _section(section_id: str) -> dict:
+ return next(s for s in build_schema()["sections"] if s["id"] == section_id)
+
+
+def test_tracing_and_metrics_sections() -> None:
+ tracing, metrics = _section("observability"), _section("metrics")
+ assert tracing["widget"] == "observability" and metrics["widget"] == "metrics"
+ assert tracing["title"] == "AI tracing" and tracing["key"] == "observability"
+ assert tracing["self_managed"] is True and metrics["self_managed"] is True
+ assert tracing["notes"] == [] and tracing["default"]["user_id_mode"] == "raw"
+ assert all(
+ s.get("self_managed", False) is False
+ for s in build_schema()["sections"]
+ if s["id"] not in ("observability", "metrics")
+ )
+ assert metrics["key"] == "metrics" and metrics["optional"] is True
+ assert "metrics" in KNOWN_TOP_LEVEL_KEYS
+ assert section_for_key("metrics") == "metrics"
+ paths = {".".join(f["path"]) for f in tracing["fields"]}
+ assert {"observability.otlp.endpoint", "observability.otlp.use_metrics_endpoint"} <= paths
+ assert all(f["managed"] for f in tracing["fields"] + metrics["fields"])
+ assert {".".join(f["path"]) for f in metrics["fields"]} == {
+ "metrics.export",
+ "metrics.otlp.endpoint",
+ "metrics.prometheus.path",
+ "metrics.prometheus.worker_port",
+ "metrics.latency_buckets_ms",
+ }
+ names = {e["name"] for e in metrics["env"]}
+ assert names == {
+ "OTEL_EXPORTER_OTLP_HEADERS",
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
+ "OTEL_SERVICE_NAME",
+ }
+ from tenxgraph_api.src.app.core.config.metrics import DEFAULT_BUCKETS_MS
+
+ buckets = next(f for f in metrics["fields"] if f["path"][-1] == "latency_buckets_ms")
+ assert buckets["default"] == DEFAULT_BUCKETS_MS
+ assert metrics["default"] == {"enabled": True, "export": "otlp", "otlp": {"endpoint": None}}
+
+
+def test_full_telemetry_config_round_trips(tmp_path: Path) -> None:
+ store = ConfigFileStore(tmp_path / "10xgraph.json")
+ store.save(_FULL_TELEMETRY, None)
+ assert json.loads((tmp_path / "10xgraph.json").read_text()) == _FULL_TELEMETRY
+ issues = validate_config(_FULL_TELEMETRY)
+ assert [i for i in issues if i["section"] in ("observability", "metrics")] == []
+
+
+def test_metrics_validation() -> None:
+ def check(metrics: object) -> list[dict]:
+ issues = validate_config({"agent": "a:b", "metrics": metrics})
+ return [i for i in issues if i["section"] == "metrics"]
+
+ assert [i["path"] for i in check({"enabled": True})] == ["metrics.export"]
+ assert [i["path"] for i in check({"enabled": True, "export": "otlp"})] == [
+ "metrics.otlp.endpoint"
+ ]
+ assert check({"enabled": True, "export": "prometheus"}) == []
+ assert check({"enabled": False}) == []
+ assert check({"latency_buckets_ms": [5, 1]})[0]["path"] == "metrics.latency_buckets_ms"
+ assert check({"prometheus": {"worker_port": 0}})[0]["path"] == "metrics.prometheus.worker_port"
+ assert check("on")[0]["path"] == "metrics"
+
+
+def test_otlp_tracing_checks(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
+
+ def check(otlp: dict, metrics: dict | None = None) -> list[dict]:
+ config: dict = {"agent": "a:b", "observability": {"otlp": {"enabled": True, **otlp}}}
+ if metrics is not None:
+ config["metrics"] = metrics
+ return [i for i in validate_config(config) if i["path"].startswith("observability")]
+
+ missing = check({})
+ assert [(i["level"], i["path"]) for i in missing] == [("error", "observability.otlp.endpoint")]
+ assert check({"endpoint": "http://collector:4318"}) == []
+
+ shared = "observability.otlp.use_metrics_endpoint"
+ unset = check({"use_metrics_endpoint": True})
+ assert [(i["level"], i["path"]) for i in unset] == [("warning", shared)]
+ prom_metrics = {"enabled": True, "export": "prometheus"}
+ assert [i["level"] for i in check({"use_metrics_endpoint": True}, prom_metrics)] == ["warning"]
+ good = {"enabled": True, "export": "otlp", "otlp": {"endpoint": "http://collector:4318"}}
+ assert check({"use_metrics_endpoint": True}, good) == []
+
+ prom = {"enabled": True, "export": "otlp", "otlp": {"endpoint": "http://p:9090/api/v1/otlp"}}
+ assert [i["path"] for i in check({"use_metrics_endpoint": True}, prom)] == [shared]
+ own = check({"endpoint": "http://p:9090/api/v1/otlp"})
+ assert [(i["level"], i["path"]) for i in own] == [("warning", "observability.otlp.endpoint")]
+
+ bad = check({"use_metrics_endpoint": "yes"})
+ assert _levels(bad, shared) == ["error"]
+
+
+def test_nothing_traced_warning_counts_otlp() -> None:
+ none = validate_config({"agent": "a:b", "observability": {"otlp": {"enabled": False}}})
+ warn = next(i for i in none if i["path"] == "observability")
+ assert "OTLP" in warn["message"]
+ on = validate_config(
+ {"agent": "a:b", "observability": {"otlp": {"enabled": True, "endpoint": "http://c:4318"}}}
+ )
+ assert _levels(on, "observability") == []
diff --git a/tests/unit_tests/test_group3a_storage.py b/tests/unit_tests/test_group3a_storage.py
index fd87def..b7326a8 100644
--- a/tests/unit_tests/test_group3a_storage.py
+++ b/tests/unit_tests/test_group3a_storage.py
@@ -262,10 +262,15 @@ async def test_message_media_still_in_state_through_another_message_is_kept(monk
@pytest.mark.asyncio
async def test_boot_runs_the_checkpointer_setup(monkeypatch):
- checkpointer = MagicMock()
+ # A real container, bound the way StateGraph.compile() binds it (the class, not a string
+ # key): a mocked "checkpointer" key hid that boot never found the graph's checkpointer.
+ from injectq import InjectQ
+ from tenxgraph.storage.checkpointer import BaseCheckpointer, InMemoryCheckpointer
+
+ checkpointer = InMemoryCheckpointer()
checkpointer.asetup = AsyncMock(return_value=True)
- container = MagicMock()
- container.try_get.side_effect = lambda name: checkpointer if name == "checkpointer" else None
+ container = InjectQ()
+ container.bind_instance(BaseCheckpointer, checkpointer, allow_concrete=True)
monkeypatch.setattr(app_main, "container", container)
await app_main._prepare_checkpointer()
checkpointer.asetup.assert_awaited_once()
@@ -273,12 +278,16 @@ async def test_boot_runs_the_checkpointer_setup(monkeypatch):
@pytest.mark.asyncio
async def test_boot_survives_a_failing_setup(monkeypatch):
- checkpointer = MagicMock()
+ from injectq import InjectQ
+ from tenxgraph.storage.checkpointer import BaseCheckpointer, InMemoryCheckpointer
+
+ checkpointer = InMemoryCheckpointer()
checkpointer.asetup = AsyncMock(side_effect=ConnectionError("postgres down"))
- container = MagicMock()
- container.try_get.return_value = checkpointer
+ container = InjectQ()
+ container.bind_instance(BaseCheckpointer, checkpointer, allow_concrete=True)
monkeypatch.setattr(app_main, "container", container)
await app_main._prepare_checkpointer() # logs, does not raise
+ checkpointer.asetup.assert_awaited_once()
@pytest.mark.asyncio
diff --git a/tests/unit_tests/test_metrics.py b/tests/unit_tests/test_metrics.py
new file mode 100644
index 0000000..0793332
--- /dev/null
+++ b/tests/unit_tests/test_metrics.py
@@ -0,0 +1,773 @@
+"""The ``metrics`` block: config, MeterProvider setup, API wiring. The venv has no OpenTelemetry,
+so the SDK, exporters and instrumentor are replaced by mocks in ``sys.modules``."""
+
+import asyncio
+import logging
+import os
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+
+from tenxgraph_api.src.app.core.auth.route_guard import (
+ DEFAULT_PUBLIC_PATHS,
+ find_unprotected_routes,
+)
+from tenxgraph_api.src.app.core.config import metrics as metrics_module
+from tenxgraph_api.src.app.core.config import setup_middleware as sm
+from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
+from tenxgraph_api.src.app.core.config.metrics import (
+ MetricsConfig,
+ flush_telemetry,
+ native_telemetry_off,
+ resolve_use_metrics_endpoint,
+ setup_metrics,
+)
+
+
+HTTP_OK = 200
+HTTP_BAD_REQUEST = 400
+PROMETHEUS_PORT = 9464
+
+
+def _otel_modules(**extra):
+ modules = {
+ name: MagicMock()
+ for name in (
+ "opentelemetry",
+ "opentelemetry.metrics",
+ "opentelemetry.trace",
+ "opentelemetry.sdk",
+ "opentelemetry.sdk.trace",
+ "opentelemetry.sdk.trace.export",
+ "opentelemetry.sdk.metrics",
+ "opentelemetry.sdk.metrics.view",
+ "opentelemetry.sdk.metrics.export",
+ "opentelemetry.sdk.resources",
+ "opentelemetry.exporter",
+ "opentelemetry.exporter.otlp",
+ "opentelemetry.exporter.otlp.proto",
+ "opentelemetry.exporter.otlp.proto.http",
+ "opentelemetry.exporter.otlp.proto.http.metric_exporter",
+ "opentelemetry.exporter.prometheus",
+ "opentelemetry.instrumentation",
+ "opentelemetry.instrumentation.fastapi",
+ )
+ }
+ modules["opentelemetry"].metrics = modules["opentelemetry.metrics"]
+ modules["opentelemetry"].trace = modules["opentelemetry.trace"]
+ modules.update(extra)
+ return modules
+
+
+def _cfg(**kwargs):
+ return MetricsConfig.model_validate(
+ {"enabled": True, "export": "otlp", "otlp": {"endpoint": "http://c:4318"}, **kwargs}
+ )
+
+
+# ── config ────────────────────────────────────────────────────────────────────
+
+
+def test_defaults_are_off_and_valid():
+ cfg = MetricsConfig()
+ assert not cfg.enabled
+ assert cfg.scrape_path is None
+ assert cfg.prometheus.path == "/metrics"
+ assert cfg.prometheus.worker_port == PROMETHEUS_PORT
+ assert cfg.latency_buckets_ms[0] == 50
+
+
+def test_export_required_when_enabled():
+ with pytest.raises(ValidationError, match="export is required"):
+ MetricsConfig(enabled=True)
+
+
+def test_otlp_endpoint_required_unless_env_set(monkeypatch):
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", raising=False)
+ with pytest.raises(ValidationError, match=r"otlp\.endpoint is required"):
+ MetricsConfig(enabled=True, export="otlp")
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://x/v1/metrics")
+ assert MetricsConfig(enabled=True, export="otlp").enabled
+
+
+@pytest.mark.parametrize("buckets", [[], [0, 10], [-5, 10], [100, 50], [10, 10]])
+def test_buckets_must_be_positive_and_sorted(buckets):
+ with pytest.raises(ValidationError, match="latency_buckets_ms"):
+ MetricsConfig(latency_buckets_ms=buckets)
+
+
+def test_unknown_key_and_bad_export_rejected():
+ with pytest.raises(ValidationError):
+ MetricsConfig(bogus=1)
+ with pytest.raises(ValidationError):
+ MetricsConfig(enabled=True, export="statsd")
+
+
+def test_scrape_path_only_for_enabled_prometheus():
+ assert MetricsConfig(enabled=True, export="prometheus").scrape_path == "/metrics"
+ assert _cfg().scrape_path is None
+
+
+def test_graph_config_exposes_metrics_and_raises_on_invalid(tmp_path):
+ path = tmp_path / "10xgraph.json"
+ path.write_text('{"agent": "a:b", "metrics": {"enabled": true}}')
+ with pytest.raises(ValidationError):
+ _ = GraphConfig(str(path)).metrics
+ path.write_text('{"agent": "a:b"}')
+ assert not GraphConfig(str(path)).metrics.enabled
+
+
+# ── setup_metrics ─────────────────────────────────────────────────────────────
+
+
+def test_setup_otlp_pushes_to_v1_metrics_with_views_and_resource(monkeypatch):
+ monkeypatch.delenv("OTEL_SEMCONV_STABILITY_OPT_IN", raising=False)
+ modules = _otel_modules()
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics") as bridge,
+ ):
+ provider = setup_metrics(_cfg(), role="api")
+
+ sdk = modules["opentelemetry.sdk.metrics"]
+ assert provider is sdk.MeterProvider.return_value
+ assert os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] == "http"
+ exporter = modules["opentelemetry.exporter.otlp.proto.http.metric_exporter"]
+ exporter.OTLPMetricExporter.assert_called_once_with(endpoint="http://c:4318/v1/metrics")
+ export = modules["opentelemetry.sdk.metrics.export"]
+ export.PeriodicExportingMetricReader.assert_called_once_with(
+ exporter.OTLPMetricExporter.return_value
+ )
+ view = modules["opentelemetry.sdk.metrics.view"]
+ units = [c.kwargs["instrument_unit"] for c in view.View.call_args_list]
+ assert units == ["ms", "s"]
+ seconds = view.ExplicitBucketHistogramAggregation.call_args_list[1].args[0]
+ assert seconds[0] == pytest.approx(0.05)
+ resource = modules["opentelemetry.sdk.resources"].Resource.create.call_args.args[0]
+ assert "service.name" in resource
+ assert resource["service.instance.id"].endswith(f"-{os.getpid()}")
+ modules["opentelemetry.metrics"].set_meter_provider.assert_called_once_with(provider)
+ provider.get_meter.assert_called_once_with("10xgraph")
+ bridge.assert_called_once_with(provider.get_meter.return_value)
+
+
+def test_global_meter_provider_set_earlier_warns_and_bridges_to_ours():
+ modules = _otel_modules() # get_meter_provider returns another object: set was refused
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics") as bridge,
+ patch.object(metrics_module.logger, "warning") as warning,
+ ):
+ provider = setup_metrics(_cfg(), role="api")
+ assert "Another global MeterProvider" in warning.call_args.args[0]
+ bridge.assert_called_once_with(provider.get_meter.return_value)
+
+
+def test_no_warning_when_our_provider_is_global():
+ modules = _otel_modules()
+ sdk = modules["opentelemetry.sdk.metrics"]
+ modules[
+ "opentelemetry.metrics"
+ ].get_meter_provider.return_value = sdk.MeterProvider.return_value
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics"),
+ patch.object(metrics_module.logger, "warning") as warning,
+ ):
+ setup_metrics(_cfg(), role="api")
+ warning.assert_not_called()
+
+
+def test_setup_failure_warns_and_returns_none():
+ modules = _otel_modules()
+ exporter = modules["opentelemetry.exporter.otlp.proto.http.metric_exporter"]
+ exporter.OTLPMetricExporter.side_effect = ValueError("bad endpoint")
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics") as bridge,
+ patch.object(metrics_module.logger, "warning") as warning,
+ ):
+ assert setup_metrics(_cfg(), role="api") is None
+ assert "bad endpoint" in str(warning.call_args.args[1])
+ modules["opentelemetry.metrics"].set_meter_provider.assert_not_called()
+ bridge.assert_not_called()
+
+
+def test_otlp_endpoint_already_ending_in_v1_metrics_is_kept():
+ modules = _otel_modules()
+ cfg = _cfg(otlp={"endpoint": "http://prom:9090/api/v1/otlp/v1/metrics"})
+ with patch.dict("sys.modules", modules), patch("tenxgraph.utils.metrics.setup_otel_metrics"):
+ setup_metrics(cfg, role="api")
+ exporter = modules["opentelemetry.exporter.otlp.proto.http.metric_exporter"]
+ exporter.OTLPMetricExporter.assert_called_once_with(
+ endpoint="http://prom:9090/api/v1/otlp/v1/metrics"
+ )
+
+
+def test_otlp_without_endpoint_lets_the_exporter_read_the_env(monkeypatch):
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://env/v1/metrics")
+ modules = _otel_modules()
+ cfg = MetricsConfig(enabled=True, export="otlp")
+ with patch.dict("sys.modules", modules), patch("tenxgraph.utils.metrics.setup_otel_metrics"):
+ setup_metrics(cfg, role="api")
+ exporter = modules["opentelemetry.exporter.otlp.proto.http.metric_exporter"]
+ exporter.OTLPMetricExporter.assert_called_once_with(endpoint=None)
+
+
+def test_prometheus_api_logs_info_and_starts_no_server(caplog):
+ modules = _otel_modules(prometheus_client=MagicMock())
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics"),
+ caplog.at_level(logging.INFO, logger="10xgraph-api"),
+ ):
+ setup_metrics(cfg, role="api")
+ modules["opentelemetry.exporter.prometheus"].PrometheusMetricReader.assert_called_once_with()
+ modules["prometheus_client"].start_http_server.assert_not_called()
+ assert any(
+ "one process" in r.message or "this process only" in r.message for r in caplog.records
+ )
+
+
+def test_prometheus_worker_starts_server():
+ modules = _otel_modules(prometheus_client=MagicMock())
+ cfg = MetricsConfig(enabled=True, export="prometheus", prometheus={"worker_port": 9999})
+ with patch.dict("sys.modules", modules), patch("tenxgraph.utils.metrics.setup_otel_metrics"):
+ setup_metrics(cfg, role="worker")
+ modules["prometheus_client"].start_http_server.assert_called_once_with(9999)
+
+
+def test_prometheus_worker_port_in_use_warns_and_continues():
+ client = MagicMock()
+ client.start_http_server.side_effect = OSError("Address already in use")
+ modules = _otel_modules(prometheus_client=client)
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ with (
+ patch.dict("sys.modules", modules),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics"),
+ patch.object(metrics_module.logger, "warning") as warning,
+ ):
+ provider = setup_metrics(cfg, role="worker")
+ assert provider is not None
+ message = warning.call_args.args[0] % warning.call_args.args[1:]
+ assert str(PROMETHEUS_PORT) in message
+ assert "OTLP push" in message
+ assert "--workers 1" in message
+
+
+def test_missing_packages_warn_and_return_none():
+ with (
+ patch.dict("sys.modules", {"opentelemetry": None}),
+ patch.object(metrics_module.logger, "warning") as warning,
+ ):
+ assert setup_metrics(_cfg(), role="api") is None
+ assert "10xgraph-api[metrics]" in warning.call_args.args[1]
+
+
+def _flush(meter_provider, tracer_provider):
+ trace = MagicMock()
+ trace.get_tracer_provider.return_value = tracer_provider
+ otel = MagicMock(trace=trace)
+ with patch.dict("sys.modules", {"opentelemetry": otel, "opentelemetry.trace": trace}):
+ asyncio.run(flush_telemetry(meter_provider))
+
+
+def test_flush_telemetry_shuts_down_metrics_and_flushes_ai_tracing_with_a_bound():
+ meter, tracer = MagicMock(), MagicMock()
+ tracer.force_flush.return_value = True
+ with patch.object(metrics_module.logger, "warning") as warning:
+ _flush(meter, tracer)
+ meter.shutdown.assert_called_once_with(timeout_millis=5000)
+ tracer.force_flush.assert_called_once_with(timeout_millis=5000)
+ tracer.shutdown.assert_not_called() # a provider owned by Logfire stays usable
+ warning.assert_not_called()
+
+
+def test_flush_telemetry_never_raises_and_warns():
+ meter, tracer = MagicMock(), MagicMock()
+ meter.shutdown.side_effect = RuntimeError("collector down")
+ tracer.force_flush.return_value = False # the SDK's timeout result
+ with patch.object(metrics_module.logger, "warning") as warning:
+ _flush(meter, tracer)
+ _flush(None, object()) # nothing configured: no force_flush
+ messages = [c.args[0] % c.args[1:] for c in warning.call_args_list]
+ assert any("Metrics shutdown failed" in m and "collector down" in m for m in messages)
+ assert any("AI tracing flush did not finish" in m for m in messages)
+ assert len(messages) == 2
+
+
+def test_flush_telemetry_is_bounded_when_a_provider_ignores_the_timeout(monkeypatch):
+ monkeypatch.setattr(metrics_module, "TELEMETRY_FLUSH_SECONDS", 0.05)
+ meter = MagicMock()
+ meter.shutdown.side_effect = lambda **kw: time.sleep(1.5)
+ started = time.monotonic()
+ with patch.object(metrics_module.logger, "warning") as warning:
+ _flush(meter, None)
+ # asyncio.run joins the leftover thread after the bound, so allow for the 1.5 s sleep.
+ assert time.monotonic() - started < 2.5
+ message = warning.call_args.args[0] % warning.call_args.args[1:]
+ assert "Metrics shutdown did not finish within 0.05s" in message
+
+
+def test_native_fastapi_telemetry_is_turned_off_when_supported():
+ def new_init(self, *, title="", telemetry=None):
+ """FastAPI with native telemetry."""
+
+ def old_init(self, *, title=""):
+ """FastAPI before native telemetry."""
+
+ with patch.object(FastAPI, "__init__", new_init):
+ assert native_telemetry_off() == {
+ "telemetry": {"tracing": False, "metrics": False, "logs": False}
+ }
+ with patch.object(FastAPI, "__init__", old_init):
+ assert native_telemetry_off() == {}
+
+
+# ── API wiring ────────────────────────────────────────────────────────────────
+
+
+def _graph_config(metrics: MetricsConfig, observability=None):
+ config = MagicMock()
+ config.metrics = metrics
+ config.observability = observability
+ config.rate_limit = None
+ return config
+
+
+def _settings(otel_enabled=False):
+ settings = MagicMock()
+ settings.OTEL_ENABLED = otel_enabled
+ settings.ORIGINS = "http://localhost"
+ settings.CORS_ALLOW_CREDENTIALS = True
+ settings.MODE = "development"
+ settings.ALLOWED_HOST = "*"
+ settings.SECURITY_HEADERS_ENABLED = False
+ settings.MAX_REQUEST_SIZE = 1_000_000
+ return settings
+
+
+def test_single_instrument_call_with_noop_tracer_when_otel_disabled():
+ modules = _otel_modules()
+ provider = MagicMock()
+ app = FastAPI()
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "get_settings", return_value=_settings(False)),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=provider),
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(_cfg()), container=None)
+
+ instrument = modules["opentelemetry.instrumentation.fastapi"].FastAPIInstrumentor.instrument_app
+ instrument.assert_called_once()
+ kwargs = instrument.call_args.kwargs
+ assert (
+ kwargs["tracer_provider"] is modules["opentelemetry.trace"].NoOpTracerProvider.return_value
+ )
+ assert kwargs["meter_provider"] is provider
+ assert app.state.meter_provider is provider
+
+
+def test_metrics_and_legacy_otel_share_one_instrument_call_and_one_meter_provider(caplog):
+ modules = _otel_modules()
+ provider = MagicMock()
+ app = FastAPI()
+ settings = _settings(True)
+ settings.OTEL_SERVICE_NAME = "svc"
+ settings.OTEL_EXPORTER_OTLP_ENDPOINT = None
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "get_settings", return_value=settings),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=provider),
+ patch.object(sm, "_setup_otel_metrics") as legacy_metrics,
+ patch.object(sm, "_attach_otel_publisher"),
+ caplog.at_level(logging.INFO, logger="10xgraph-api"),
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(_cfg()), container=None)
+
+ legacy_metrics.assert_not_called()
+ assert any("metrics come from the metrics block" in r.message for r in caplog.records)
+ instrument = modules["opentelemetry.instrumentation.fastapi"].FastAPIInstrumentor.instrument_app
+ instrument.assert_called_once()
+ assert "tracer_provider" not in instrument.call_args.kwargs # the global SDK provider
+ assert instrument.call_args.kwargs["meter_provider"] is provider
+
+
+@pytest.mark.parametrize("failure", ["no_endpoint", "no_grpc_exporter"])
+def test_failed_legacy_otel_still_gets_http_metrics_from_the_metrics_block(failure):
+ modules = _otel_modules()
+ settings = _settings(True)
+ settings.OTEL_SERVICE_NAME = "svc"
+ if failure == "no_endpoint":
+ settings.MODE = "production"
+ settings.OTEL_EXPORTER_OTLP_ENDPOINT = None
+ else:
+ settings.OTEL_EXPORTER_OTLP_ENDPOINT = "http://c:4317"
+ modules["opentelemetry.exporter.otlp.proto.grpc.trace_exporter"] = None # ImportError
+ provider = MagicMock()
+ app = FastAPI()
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "get_settings", return_value=settings),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=provider),
+ patch.object(sm, "_attach_otel_publisher"),
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(_cfg()), container=None)
+
+ instrument = modules["opentelemetry.instrumentation.fastapi"].FastAPIInstrumentor.instrument_app
+ instrument.assert_called_once()
+ kwargs = instrument.call_args.kwargs
+ assert (
+ kwargs["tracer_provider"] is modules["opentelemetry.trace"].NoOpTracerProvider.return_value
+ )
+ assert kwargs["meter_provider"] is provider
+
+
+def test_scrape_path_skips_the_host_check_like_the_probes():
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ fake_client = MagicMock()
+ fake_client.CONTENT_TYPE_LATEST = "text/plain"
+ fake_client.generate_latest.return_value = b"up 1\n"
+ settings = _settings(False)
+ settings.ALLOWED_HOST = "api.example.com"
+ app = FastAPI()
+ app.get("/other")(lambda: {"ok": True})
+ with (
+ patch.dict("sys.modules", {**_otel_modules(), "prometheus_client": fake_client}),
+ patch.object(sm, "get_settings", return_value=settings),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=MagicMock()),
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(cfg), container=None)
+ client = TestClient(app)
+ for host in ("10.1.2.3:8000", "api:8000"):
+ assert client.get("/metrics", headers={"host": host}).status_code == HTTP_OK
+ assert (
+ client.get("/other", headers={"host": "10.1.2.3:8000"}).status_code == HTTP_BAD_REQUEST
+ )
+ assert (
+ client.get("/metrics/x", headers={"host": "10.1.2.3:8000"}).status_code
+ == HTTP_BAD_REQUEST
+ )
+
+
+def test_legacy_otel_without_metrics_block_keeps_legacy_metrics():
+ settings = MagicMock()
+ settings.OTEL_SERVICE_NAME = "svc"
+ settings.OTEL_EXPORTER_OTLP_ENDPOINT = None
+ settings.MODE = "development"
+ modules = _otel_modules()
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "_setup_otel_metrics") as legacy_metrics,
+ ):
+ assert sm.setup_otel_provider(settings) is True
+ legacy_metrics.assert_called_once()
+
+
+def test_prometheus_route_serves_registry_without_auth_and_passes_the_guard():
+ modules = _otel_modules()
+ cfg = MetricsConfig(enabled=True, export="prometheus", prometheus={"path": "/scrape"})
+ fake_client = MagicMock()
+ fake_client.CONTENT_TYPE_LATEST = "text/plain; version=0.0.4; charset=utf-8"
+ fake_client.generate_latest.return_value = b"# HELP up\nup 1\n"
+ app = FastAPI()
+ with (
+ patch.dict("sys.modules", {**modules, "prometheus_client": fake_client}),
+ patch.object(sm, "get_settings", return_value=_settings(False)),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=MagicMock()),
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(cfg), container=None)
+ response = TestClient(app).get("/scrape")
+
+ assert response.status_code == HTTP_OK
+ assert response.content == b"# HELP up\nup 1\n"
+ assert response.headers["content-type"].startswith("text/plain")
+ assert find_unprotected_routes(app, DEFAULT_PUBLIC_PATHS | {"/scrape"}) == []
+ assert find_unprotected_routes(app) == ["GET /scrape"]
+
+
+def test_scrape_path_is_excluded_from_rate_limiting():
+ from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
+ from tenxgraph_api.src.app.core.middleware.rate_limit import RateLimitMiddleware
+
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ graph_config = _graph_config(cfg)
+ graph_config.rate_limit = RateLimitConfig.from_dict({"exclude_paths": ["/docs"]})
+ modules = _otel_modules(prometheus_client=MagicMock())
+ app = FastAPI()
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "get_settings", return_value=_settings(False)),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics", return_value=MagicMock()),
+ ):
+ sm.setup_middleware(app, graph_config=graph_config, container=MagicMock())
+
+ limiter = next(m for m in app.user_middleware if m.cls is RateLimitMiddleware)
+ assert limiter.kwargs["config"].exclude_paths == ("/docs", "/metrics")
+
+
+def test_metrics_off_does_nothing():
+ modules = _otel_modules()
+ app = FastAPI()
+ with (
+ patch.dict("sys.modules", modules),
+ patch.object(sm, "get_settings", return_value=_settings(False)),
+ patch.object(sm, "init_sentry"),
+ patch.object(sm, "setup_metrics") as setup,
+ ):
+ sm.setup_middleware(app, graph_config=_graph_config(MetricsConfig()), container=None)
+ setup.assert_not_called()
+ modules[
+ "opentelemetry.instrumentation.fastapi"
+ ].FastAPIInstrumentor.instrument_app.assert_not_called()
+
+
+# ── observability.otlp.use_metrics_endpoint ──────────────────────────────────
+
+
+OBS = {"otlp": {"enabled": True, "endpoint": None, "use_metrics_endpoint": True}}
+
+
+def test_use_metrics_endpoint_copies_the_endpoint_without_mutating():
+ resolved = resolve_use_metrics_endpoint(OBS, _cfg())
+ assert resolved["otlp"]["endpoint"] == "http://c:4318"
+ assert resolved["otlp"]["enabled"] is True
+ assert OBS["otlp"]["endpoint"] is None
+
+
+def test_use_metrics_endpoint_strips_v1_metrics_and_warns_for_prometheus():
+ cfg = _cfg(otlp={"endpoint": "http://prom:9090/api/v1/otlp/v1/metrics/"})
+ with patch.object(sm.logger, "warning") as warning:
+ resolved = resolve_use_metrics_endpoint(OBS, cfg)
+ assert resolved["otlp"]["endpoint"] == "http://prom:9090/api/v1/otlp"
+ assert "Prometheus accepts metrics only" in warning.call_args.args[0]
+
+
+def test_use_metrics_endpoint_collector_does_not_warn():
+ cfg = _cfg(otlp={"endpoint": "http://collector:4318/v1/metrics"})
+ with patch.object(sm.logger, "warning") as warning:
+ resolved = resolve_use_metrics_endpoint(OBS, cfg)
+ assert resolved["otlp"]["endpoint"] == "http://collector:4318"
+ warning.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "metrics",
+ [MetricsConfig(), MetricsConfig(enabled=True, export="prometheus")],
+)
+def test_use_metrics_endpoint_without_otlp_metrics_warns_and_disables(metrics):
+ with patch.object(sm.logger, "warning") as warning:
+ resolved = resolve_use_metrics_endpoint(OBS, metrics)
+ assert resolved["otlp"]["enabled"] is False
+ assert "use_metrics_endpoint" in warning.call_args.args[0]
+ assert OBS["otlp"]["enabled"] is True
+
+
+def test_setup_observability_hands_resolved_otlp_to_core():
+ container = MagicMock()
+ container.try_get.return_value = None
+ graph_config = _graph_config(_cfg(), observability={"level": "standard", **OBS})
+ with (
+ patch("tenxgraph.runtime.publisher.exporters.setup_observability") as core_setup,
+ patch.object(sm, "_bind_publisher"),
+ ):
+ sm._setup_observability(container, graph_config)
+ core_setup.assert_called_once()
+ assert core_setup.call_args.args[1]["otlp"]["endpoint"] == "http://c:4318"
+
+
+def test_setup_observability_runs_for_otlp_alone():
+ container = MagicMock()
+ container.try_get.return_value = None
+ obs = {"otlp": {"enabled": True, "endpoint": "http://t:4318"}}
+ with (
+ patch("tenxgraph.runtime.publisher.exporters.setup_observability") as core_setup,
+ patch.object(sm, "_bind_publisher"),
+ ):
+ sm._setup_observability(container, _graph_config(MetricsConfig(), observability=obs))
+ core_setup.assert_called_once()
+
+
+# ── deferred review fixes ────────────────────────────────────────────────────
+
+
+@pytest.fixture
+def semconv_env(monkeypatch):
+ monkeypatch.delenv("OTEL_SEMCONV_STABILITY_OPT_IN", raising=False)
+ return monkeypatch
+
+
+@pytest.mark.parametrize(
+ ("preset", "expected"),
+ [
+ (None, "http"),
+ ("database", "database,http"),
+ (" database , gen_ai ", "database,gen_ai,http"),
+ ("http", "http"),
+ ("database,http/dup", "database,http/dup"),
+ ],
+)
+def test_semconv_opt_in_is_merged_not_replaced(semconv_env, preset, expected):
+ if preset is not None:
+ semconv_env.setenv("OTEL_SEMCONV_STABILITY_OPT_IN", preset)
+ metrics_module._use_stable_http_names()
+ assert os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] == expected
+
+
+def _fake_semconv(initialized, *, http_mode="default"):
+ fake = MagicMock()
+ fake._StabilityMode.DEFAULT = "default"
+ stability = fake._OpenTelemetrySemanticConventionStability
+ stability._initialized = initialized
+ stability._get_opentelemetry_stability_opt_in_mode.return_value = http_mode
+ return {"opentelemetry.instrumentation._semconv": fake}
+
+
+def test_semconv_already_read_warns_about_old_names(semconv_env, caplog):
+ with (
+ patch.dict("sys.modules", _fake_semconv(True)),
+ caplog.at_level(logging.WARNING, logger="10xgraph-api"),
+ ):
+ metrics_module._use_stable_http_names()
+ assert any("old names" in r.message and r.levelno == logging.WARNING for r in caplog.records)
+
+
+def test_semconv_already_read_with_http_mode_does_not_warn(semconv_env, caplog):
+ with (
+ patch.dict("sys.modules", _fake_semconv(True, http_mode="http")),
+ caplog.at_level(logging.WARNING, logger="10xgraph-api"),
+ ):
+ metrics_module._use_stable_http_names()
+ assert not any("old names" in r.message for r in caplog.records)
+
+
+def test_semconv_not_yet_read_does_not_warn(semconv_env, caplog):
+ with (
+ patch.dict("sys.modules", _fake_semconv(False)),
+ caplog.at_level(logging.WARNING, logger="10xgraph-api"),
+ ):
+ metrics_module._use_stable_http_names()
+ assert not any("old names" in r.message for r in caplog.records)
+
+
+def test_scrape_path_must_not_be_root_or_relative():
+ with pytest.raises(ValidationError):
+ MetricsConfig(prometheus={"path": "/"})
+ with pytest.raises(ValidationError):
+ MetricsConfig(prometheus={"path": "metrics"})
+
+
+def _app_with_scrape(path, *real_paths):
+ app = FastAPI()
+ for real in real_paths:
+ app.add_api_route(real, lambda: None, methods=["GET"])
+ app.add_api_route(path, lambda: None, methods=["GET"])
+ app.state.scrape_route = app.routes[-1]
+ return app
+
+
+def test_scrape_route_clash_with_real_route_fails_naming_it():
+ app = _app_with_scrape("/ping", "/ping")
+ with pytest.raises(ValueError, match="'/ping'"):
+ metrics_module.check_scrape_route(app, app.state.scrape_route)
+
+
+def test_scrape_route_clash_with_parameterised_route_fails():
+ app = _app_with_scrape("/v1/threads/metrics", "/v1/threads/{thread_id}")
+ with pytest.raises(ValueError, match=r"/v1/threads/\{thread_id\}"):
+ metrics_module.check_scrape_route(app, app.state.scrape_route)
+
+
+def test_scrape_route_without_clash_or_without_route_passes():
+ app = _app_with_scrape("/metrics", "/ping", "/v1/graph")
+ metrics_module.check_scrape_route(app, app.state.scrape_route)
+ metrics_module.check_scrape_route(app, None)
+
+
+def test_prometheus_notice_is_a_warning_with_several_workers(monkeypatch, caplog):
+ monkeypatch.setenv("WEB_CONCURRENCY", "4")
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ with caplog.at_level(logging.INFO, logger="10xgraph-api"):
+ metrics_module._announce_scrape(cfg, "api")
+ record = next(r for r in caplog.records if "this process only" in r.message)
+ assert record.levelno == logging.WARNING
+
+
+def test_prometheus_notice_is_info_with_one_worker(monkeypatch, caplog):
+ monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
+ cfg = MetricsConfig(enabled=True, export="prometheus")
+ with caplog.at_level(logging.INFO, logger="10xgraph-api"):
+ metrics_module._announce_scrape(cfg, "api")
+ record = next(r for r in caplog.records if "this process only" in r.message)
+ assert record.levelno == logging.INFO
+
+
+def test_scrape_path_is_excluded_from_http_metrics_and_env_exclusions_kept(monkeypatch):
+ import re
+
+ monkeypatch.setenv("OTEL_PYTHON_FASTAPI_EXCLUDED_URLS", "healthz")
+ modules = _otel_modules()
+ app = FastAPI()
+ with patch.dict("sys.modules", modules):
+ sm._instrument_fastapi(
+ app, tracing=False, meter_provider=MagicMock(), scrape_path="/metrics"
+ )
+ instrument = modules["opentelemetry.instrumentation.fastapi"].FastAPIInstrumentor.instrument_app
+ excluded = instrument.call_args.kwargs["excluded_urls"]
+ env_part, own = excluded.split(",", 1)
+ assert env_part == "healthz"
+ assert re.search(own, "http://host:8000/metrics")
+ assert re.search(own, "http://host/metrics?x=1")
+ assert not re.search(own, "http://host/v1/metrics")
+ assert not re.search(own, "http://host/metrics/extra")
+
+
+def test_rebuild_shuts_down_the_previous_provider(monkeypatch):
+ monkeypatch.setattr(metrics_module, "_providers", [])
+ modules = _otel_modules()
+ first, second = MagicMock(), MagicMock()
+ modules["opentelemetry.sdk.metrics"].MeterProvider.side_effect = [first, second]
+ with patch.dict("sys.modules", modules), patch("tenxgraph.utils.metrics.setup_otel_metrics"):
+ setup_metrics(_cfg(), role="api")
+ first.shutdown.assert_not_called()
+ setup_metrics(_cfg(), role="api")
+ first.shutdown.assert_called_once_with()
+ second.shutdown.assert_not_called()
+
+
+def test_use_metrics_endpoint_reads_exporter_env_when_endpoint_unset(monkeypatch):
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://m:4318/v1/metrics")
+ monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://shared:4318")
+ cfg = MetricsConfig(enabled=True, export="otlp") # valid only because the env is set
+ assert resolve_use_metrics_endpoint(OBS, cfg)["otlp"]["endpoint"] == "http://m:4318"
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")
+ assert resolve_use_metrics_endpoint(OBS, cfg)["otlp"]["endpoint"] == "http://shared:4318"
+
+
+def test_use_metrics_endpoint_env_prometheus_url_still_warns(monkeypatch):
+ monkeypatch.setenv(
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://p:9090/api/v1/otlp/v1/metrics"
+ )
+ cfg = MetricsConfig(enabled=True, export="otlp")
+ with patch.object(sm.logger, "warning") as warning:
+ resolved = resolve_use_metrics_endpoint(OBS, cfg)
+ assert resolved["otlp"]["endpoint"] == "http://p:9090/api/v1/otlp"
+ assert "Prometheus accepts metrics only" in warning.call_args.args[0]
+
+
+def test_use_metrics_endpoint_without_any_endpoint_still_disables(monkeypatch):
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", raising=False)
+ monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
+ cfg = MetricsConfig.model_construct(enabled=True, export="otlp", otlp=MetricsConfig().otlp)
+ assert resolve_use_metrics_endpoint(OBS, cfg)["otlp"]["enabled"] is False
diff --git a/tests/unit_tests/test_metrics_real_packages.py b/tests/unit_tests/test_metrics_real_packages.py
new file mode 100644
index 0000000..137289a
--- /dev/null
+++ b/tests/unit_tests/test_metrics_real_packages.py
@@ -0,0 +1,77 @@
+"""End-to-end check with the real OpenTelemetry and Prometheus packages (skipped without them)."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+
+pytest.importorskip("opentelemetry.sdk")
+pytest.importorskip("opentelemetry.exporter.prometheus")
+pytest.importorskip("opentelemetry.instrumentation.fastapi")
+prometheus_client = pytest.importorskip("prometheus_client")
+
+from tenxgraph_api.src.app.core.config import metrics as metrics_module
+from tenxgraph_api.src.app.core.config import setup_middleware as sm
+from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
+
+
+HTTP_OK = 200
+
+
+def _build_app():
+ graph_config = MagicMock()
+ graph_config.metrics = MetricsConfig(
+ enabled=True, export="prometheus", prometheus={"path": "/_scrape"}
+ )
+ graph_config.rate_limit = None
+ graph_config.observability = None
+ settings = MagicMock()
+ settings.OTEL_ENABLED = False
+ settings.ORIGINS = "http://localhost"
+ settings.CORS_ALLOW_CREDENTIALS = True
+ settings.MODE = "development"
+ settings.ALLOWED_HOST = "*"
+ settings.SECURITY_HEADERS_ENABLED = False
+ settings.MAX_REQUEST_SIZE = 1_000_000
+ app = FastAPI()
+
+ @app.get("/items/{item_id}")
+ def item(item_id: int) -> dict:
+ return {"id": item_id}
+
+ with (
+ patch.object(sm, "get_settings", return_value=settings),
+ patch.object(sm, "init_sentry"),
+ patch("tenxgraph.utils.metrics.setup_otel_metrics"),
+ ):
+ sm.setup_middleware(app, graph_config=graph_config, container=None)
+ return app
+
+
+@pytest.fixture(autouse=True)
+def _clean_registry():
+ yield
+ metrics_module._shutdown_previous_provider() # unregisters the Prometheus collector
+
+
+def test_scrape_has_http_route_histogram_without_the_scrape_route():
+ client = TestClient(_build_app())
+ assert client.get("/items/7").status_code == HTTP_OK
+ assert client.get("/_scrape").status_code == HTTP_OK # not itself measured
+ body = client.get("/_scrape").text
+
+ assert "http_server_request_duration_seconds_bucket" in body
+ assert 'http_route="/items/{item_id}"' in body
+ assert "/_scrape" not in body
+
+
+def test_building_twice_does_not_duplicate_families():
+ TestClient(_build_app()).get("/items/1") # the first app has data when the second is built
+ TestClient(_build_app()).get("/items/1")
+ text = prometheus_client.generate_latest(prometheus_client.REGISTRY).decode()
+ helps = [
+ line for line in text.splitlines() if line.startswith("# HELP http_server_request_duration")
+ ]
+ assert len(helps) == 1
diff --git a/tests/unit_tests/test_middleware_order.py b/tests/unit_tests/test_middleware_order.py
index 370ba63..fa46c1c 100644
--- a/tests/unit_tests/test_middleware_order.py
+++ b/tests/unit_tests/test_middleware_order.py
@@ -12,6 +12,7 @@
from tenxgraph_api.src.app.core.config import setup_middleware as sm
from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
+from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
from tenxgraph_api.src.app.core.config.settings import Settings
@@ -28,6 +29,7 @@ def _app(allowed_host: str = "testserver", max_size: int = 10 * 1024 * 1024) ->
graph_config = SimpleNamespace(
rate_limit=RateLimitConfig.from_dict({"requests": 1, "window": 60}),
observability=None,
+ metrics=MetricsConfig(),
)
app = FastAPI()
diff --git a/tests/unit_tests/test_setup_middleware.py b/tests/unit_tests/test_setup_middleware.py
index 106fe8e..a8c13aa 100644
--- a/tests/unit_tests/test_setup_middleware.py
+++ b/tests/unit_tests/test_setup_middleware.py
@@ -6,6 +6,7 @@
from fastapi.testclient import TestClient
from tenxgraph_api.src.app.core.config import setup_middleware as setup_middleware_module
+from tenxgraph_api.src.app.core.config.metrics import MetricsConfig
from tenxgraph_api.src.app.core.config.setup_middleware import (
SelectiveGZipMiddleware,
setup_middleware,
@@ -431,6 +432,7 @@ def test_setup_middleware_all():
rate_limit_config.exclude_paths = None
rate_limit_config.trusted_proxy_headers = True
graph_config.rate_limit = rate_limit_config
+ graph_config.metrics = MetricsConfig()
container = MagicMock()
@@ -451,7 +453,7 @@ def test_setup_middleware_all():
assert len(rate_limit) == 1
assert rate_limit[0].kwargs == {"config": rate_limit_config, "container": container}
assert not hasattr(app.state, "rate_limit_backend")
- mock_setup_otel.assert_called_once_with(app, settings)
+ mock_setup_otel.assert_called_once_with(app, settings, None, None)
mock_attach.assert_called_once_with(container, settings, graph_config)
@@ -527,7 +529,11 @@ def test_setup_observability_noop_when_no_backend_enabled():
from tenxgraph_api.src.app.core.config.setup_middleware import _setup_observability
container = MagicMock()
- cfg = {"logfire": {"enabled": False}, "langsmith": {"enabled": False}}
+ cfg = {
+ "logfire": {"enabled": False},
+ "langsmith": {"enabled": False},
+ "phoenix": {"enabled": False},
+ }
_setup_observability(container, _graph_config(cfg))
container.bind_instance.assert_not_called()
@@ -566,6 +572,22 @@ def test_setup_observability_adds_to_existing_composite():
container.bind_instance.assert_not_called()
+def test_setup_observability_binds_for_phoenix_only():
+ from tenxgraph_api.src.app.core.config.setup_middleware import _setup_observability
+
+ modules, setup_obs, _base, _comp = _obs_modules()
+ cfg = {"phoenix": {"enabled": True, "project": "p", "endpoint": None}}
+ container = MagicMock()
+ container.try_get.return_value = None
+
+ with patch.dict(sys.modules, modules):
+ _setup_observability(container, _graph_config(cfg))
+
+ setup_obs.assert_called_once()
+ assert setup_obs.call_args[0][1] == cfg
+ container.bind_instance.assert_called_once()
+
+
def test_setup_observability_wraps_single_existing():
from tenxgraph_api.src.app.core.config.setup_middleware import _setup_observability
diff --git a/uv.lock b/uv.lock
index 61c1747..8b645d3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -11,7 +11,7 @@ resolution-markers = [
[[package]]
name = "10xgraph"
version = "1.0.0rc4"
-source = { git = "https://github.com/10xGraph/10xGraph?branch=main#32d6bd45a02bef9ae0ab65e443e6a567716b9d7a" }
+source = { git = "https://github.com/10xGraph/10xGraph?branch=main#0b65e3bd4a695f6a10960c82e19570220feb5c18" }
dependencies = [
{ name = "injectq" },
{ name = "pydantic" },
@@ -76,6 +76,14 @@ jwt = [
media = [
{ name = "textxtract", extra = ["docx", "html", "md", "pdf", "xml"] },
]
+metrics = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-exporter-otlp-proto-http" },
+ { name = "opentelemetry-exporter-prometheus" },
+ { name = "opentelemetry-instrumentation-fastapi" },
+ { name = "opentelemetry-sdk" },
+ { name = "prometheus-client" },
+]
otel = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp" },
@@ -131,10 +139,16 @@ requires-dist = [
{ name = "firebase-admin", marker = "extra == 'firebase'", specifier = ">=6.5.0" },
{ name = "google-cloud-logging", marker = "extra == 'gcloud'" },
{ name = "oauth2client", marker = "extra == 'firebase'", specifier = ">=4.1.3" },
+ { name = "opentelemetry-api", marker = "extra == 'metrics'", specifier = ">=1.45.1" },
{ name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.45.1" },
{ name = "opentelemetry-exporter-otlp", marker = "extra == 'otel'", specifier = ">=1.20.0" },
+ { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'metrics'", specifier = ">=1.45.2" },
+ { name = "opentelemetry-exporter-prometheus", marker = "extra == 'metrics'", specifier = ">=0.66b2" },
+ { name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'metrics'", specifier = ">=0.66b2" },
{ name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'otel'", specifier = ">=0.40b0" },
+ { name = "opentelemetry-sdk", marker = "extra == 'metrics'", specifier = ">=1.45.1" },
{ name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.45.1" },
+ { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.26.0" },
{ name = "pydantic", specifier = ">=2.13,<3" },
{ name = "pydantic-settings", specifier = ">=2.3,<3" },
{ name = "pyjwt", marker = "extra == 'jwt'", specifier = ">=2.15.0,<3" },
@@ -159,7 +173,7 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.36,<1.0" },
{ name = "websockets", marker = "extra == 'websocket'", specifier = ">=13.0,<17" },
]
-provides-extras = ["sentry", "firebase", "snowflakekit", "redis", "jobs", "jobs-rabbitmq", "jobs-kafka", "jwt", "media", "images", "gcloud", "otel", "ag-ui", "websocket"]
+provides-extras = ["sentry", "firebase", "snowflakekit", "redis", "jobs", "jobs-rabbitmq", "jobs-kafka", "jwt", "media", "images", "gcloud", "otel", "metrics", "ag-ui", "websocket"]
[package.metadata.requires-dev]
dev = [
@@ -1908,26 +1922,26 @@ wheels = [
[[package]]
name = "opentelemetry-api"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" },
]
[[package]]
name = "opentelemetry-exporter-http-transport"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/e6/dbeb3e217418071656232a4028c791060d219f218cef1249b664049d12c0/opentelemetry_exporter_http_transport-0.66b2.tar.gz", hash = "sha256:66d6ed810a515432c8891da9a834464332fd70badeca45bdeeae81b4280c903b", size = 11693, upload-time = "2026-10-29T17:29:39.444Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b2/c622a101f31c0e6670b82f4264c4e77c161bce415bc6fc158fda0b82e218/opentelemetry_exporter_http_transport-0.66b2-py3-none-any.whl", hash = "sha256:fd5f5b490c099acda1cd7b909d5e8981436bf18cc8c6098d302d114c271720e9", size = 12154, upload-time = "2026-10-29T17:29:10.244Z" },
]
[package.optional-dependencies]
@@ -1937,44 +1951,44 @@ requests = [
[[package]]
name = "opentelemetry-exporter-otlp"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/6f/5a561048ea372894f22f58f70e8478e4cd97d9a4e2d6f014559584a29bc5/opentelemetry_exporter_otlp-1.45.1.tar.gz", hash = "sha256:d0ac35592e77663a9fabf2740b4818c57196b1b764e0c8449d0d7bb2c7b2bc67", size = 6094, upload-time = "2026-10-06T17:33:00.966Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1e/45/52e9ce585d825e14cc211871acc3bc46b6cd36cdb0c09255a7feea98322b/opentelemetry_exporter_otlp-1.45.2.tar.gz", hash = "sha256:879044ba65b8b1be7a0d9d904afb76700e96d2a4d36700918d9325c9ee3b8be4", size = 6093, upload-time = "2026-10-29T17:29:40.905Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/36/e1/68c28d7da4482ce882a640b2c61405a78284c0a3a17a4db125647189b0b7/opentelemetry_exporter_otlp-1.45.1-py3-none-any.whl", hash = "sha256:ef3910d32b36ccbaf62390189759bd43a8109885e2d50b738ed9c9b255bf5cb5", size = 6730, upload-time = "2026-10-06T17:32:37.236Z" },
+ { url = "https://files.pythonhosted.org/packages/19/27/fcc3b725ddbfa274fbf6098c9583032557671d35aa8d5e08d7e7277fb126/opentelemetry_exporter_otlp-1.45.2-py3-none-any.whl", hash = "sha256:52c8b9f268289c92c08b3a97f48c63d6e5c037f25ca1f6f29ef162fb78974afc", size = 6732, upload-time = "2026-10-29T17:29:12.653Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-common"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-sdk" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/3c/3f2cc8c42bde2620e65024d71e2eb025f7bd0821bb363b52bcc5a3260dbd/opentelemetry_exporter_otlp_common-0.66b2.tar.gz", hash = "sha256:06f5a9b88638e310efa1f1d206cdf6b75393e112aba4e174a963416192bd1028", size = 14325, upload-time = "2026-10-29T17:29:41.701Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/47/b3049e30b339c82e3c7ec1fc692185a5d5a3ba65a94c570bb39b2c0b8424/opentelemetry_exporter_otlp_common-0.66b2-py3-none-any.whl", hash = "sha256:ace86efb75489f925f1ef809856ae5f0c7db534e4afdc624530222228b1236a5", size = 12385, upload-time = "2026-10-29T17:29:13.667Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/58/239c98b36d9ea317098769957043c251703636a26189c884fba46c1dad84/opentelemetry_exporter_otlp_proto_common-1.45.2.tar.gz", hash = "sha256:f1f2806f3f28ce7938033823e50934fcd91595a39de38e69a28a51e19f40fc6d", size = 18873, upload-time = "2026-10-29T17:29:44.496Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/d6/0f30d38120a949aef70528f845bf539f80df62abb4b8e62fdf69c2b6df5a/opentelemetry_exporter_otlp_proto_common-1.45.2-py3-none-any.whl", hash = "sha256:af483b24217028892f017096731b794b196b785025d2e6808874720fd0b05a80", size = 15392, upload-time = "2026-10-29T17:29:17.913Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -1986,14 +2000,14 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/2c/996861b327ddde01d3c0971582ebcd3f01a376acc720f7b0cb5274df4b70/opentelemetry_exporter_otlp_proto_grpc-1.45.2.tar.gz", hash = "sha256:9e4a6490ca7711479fec7c519425bc09808abbc9a2a1775528edecf200d19a5a", size = 25995, upload-time = "2026-10-29T17:29:45.2Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/46/2d1da202f1e17c81aae7efcf702898d524b46709e4d3e2bf1f7f8ca8fbc6/opentelemetry_exporter_otlp_proto_grpc-1.45.1-py3-none-any.whl", hash = "sha256:e42ecb789d2fc5d8145e3dadc3e2991c9f18cd166d7c7514e234702540274b76", size = 19492, upload-time = "2026-10-06T17:32:42.838Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/48/14301cac3d106e795eef7e89e191489f086b4e5ad26e47c9635a198e95f4/opentelemetry_exporter_otlp_proto_grpc-1.45.2-py3-none-any.whl", hash = "sha256:8a48d60835e90536846bd82de487bfbd81bccb5a1b3d0fb82edeea17db07395c", size = 19490, upload-time = "2026-10-29T17:29:18.934Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -2006,14 +2020,28 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/07/b7b01ecb69d02f540e679a9e36f7413d081f225bbbed4469d212ada10bb7/opentelemetry_exporter_otlp_proto_http-1.45.2.tar.gz", hash = "sha256:5984a3fb46bc4628e4e2b85d4a6e72637e03962aae6cf372791137768a4ad939", size = 28843, upload-time = "2026-10-29T17:29:46.15Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" },
+ { url = "https://files.pythonhosted.org/packages/32/65/f64d7d2450217e2989714e1dee15e19bf79a77a313387abac0cac77bc10e/opentelemetry_exporter_otlp_proto_http-1.45.2-py3-none-any.whl", hash = "sha256:6e4d025138829d0ee57946930c2ccad6491ad4d92a958e9c7eea69278fe60cfc", size = 22180, upload-time = "2026-10-29T17:29:20.154Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-prometheus"
+version = "0.66b2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-sdk" },
+ { name = "prometheus-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c5/d49a08ebd180fb1471ae44ccaa265abf56e95cb5164ed5208e1663584b06/opentelemetry_exporter_prometheus-0.66b2.tar.gz", hash = "sha256:ada6b2507a31e10405f686e6a8498cb463a6931b5791337b04cfe14288db497f", size = 17337, upload-time = "2026-10-29T17:29:46.903Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/ec/91617f3e9bbe50487fd899a9efee4d111eabc3c61b66c2ef3ecf0aaba68e/opentelemetry_exporter_prometheus-0.66b2-py3-none-any.whl", hash = "sha256:b73998788aa30bec3bfe20fae312650878adb59f6563ed8bf37c188cb450a189", size = 13371, upload-time = "2026-10-29T17:29:21.28Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
@@ -2021,14 +2049,14 @@ dependencies = [
{ name = "packaging" },
{ name = "wrapt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/b9/46a136199fa6928eecefec8418867a460d94f01c608a85d22bd2ed4e8d48/opentelemetry_instrumentation-0.66b2.tar.gz", hash = "sha256:02b094150e53b76f5906d75e628dbb3833bbc6e62fa109b5c7ba0c081865e923", size = 43305, upload-time = "2026-10-29T17:38:11.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/41/014d40e2b08a5d9d9058ed4967ad992657c203ce0c292ded97364ee7b82a/opentelemetry_instrumentation-0.66b2-py3-none-any.whl", hash = "sha256:fa9ccfdab147a14af8107d19ef0a7b5015675273c5adced7a1fc38f28b42ec4c", size = 36902, upload-time = "2026-10-29T17:37:06.994Z" },
]
[[package]]
name = "opentelemetry-instrumentation-asgi"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
@@ -2037,14 +2065,14 @@ dependencies = [
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/d9/ff522f5c3e340e9007554923b1a4d2ac451676f8757bafb3d0057f68b5c3/opentelemetry_instrumentation_asgi-0.66b1.tar.gz", hash = "sha256:78cdc5e45e897e16a8dac9d282e8d5bdf9af2d58e1313fa0bdd4a134c6f9dafc", size = 25992, upload-time = "2026-10-06T17:36:14.593Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/96/0524b34042406e632d2ec625be2432db703c2911bb594cef3280666e7b9e/opentelemetry_instrumentation_asgi-0.66b2.tar.gz", hash = "sha256:da8a2df4589976ad483feaec150a78ec7d1947754e420b838a4942361e465bfb", size = 25991, upload-time = "2026-10-29T17:38:16.291Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/67/ea/10ba99110bf3c9fb736af39c96ca8f3668b988cabb6b59309e058c44461c/opentelemetry_instrumentation_asgi-0.66b1-py3-none-any.whl", hash = "sha256:78b3f9bdf0fa38c65935a2ab46d59e0f9de873a51e0c95b0329f106e2ccb5274", size = 15812, upload-time = "2026-10-06T17:35:17.638Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/6e/bbb647fc9c789365593b77aefc949beef5fa684015e2b81fb41f5da0aa9c/opentelemetry_instrumentation_asgi-0.66b2-py3-none-any.whl", hash = "sha256:9c5ec36e6b898f18e6b07b5077c2a3bafa9abf69de98e729797b9efa465f8aa3", size = 15810, upload-time = "2026-10-29T17:37:13.306Z" },
]
[[package]]
name = "opentelemetry-instrumentation-fastapi"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
@@ -2053,57 +2081,57 @@ dependencies = [
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5d/2a/cd4125b7acbea2ed17f1d31b58c184cb0a79fcb5541ceb4de90ffc6d8c01/opentelemetry_instrumentation_fastapi-0.66b1.tar.gz", hash = "sha256:584cf9d2c4417ff8b2d6ff2bc606bfe13c8b3456018bf94f50f2cf658492505b", size = 26634, upload-time = "2026-10-06T17:36:25.157Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/71/8ccb9d84bbdbb4798682796dcf20892e404ef9548f58975c4b0dd3d372d6/opentelemetry_instrumentation_fastapi-0.66b2.tar.gz", hash = "sha256:bd226e0196990999eef77380a64527a26f35b621e681c3cad860984ecc3c23f5", size = 26631, upload-time = "2026-10-29T17:38:28.173Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/75/70/676928d537978acc7bff2ac8657bd0836ba608ffc8f65455238f1fa2bd0f/opentelemetry_instrumentation_fastapi-0.66b1-py3-none-any.whl", hash = "sha256:97f8ac8fd7537517f9e6988bd0aca04bfa5aad564bcd46c245530739e2be72d1", size = 14216, upload-time = "2026-10-06T17:35:32.827Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/10338083ccc116e65b0471796baa90fcb817daf96fed67e51eff9fb80d4e/opentelemetry_instrumentation_fastapi-0.66b2-py3-none-any.whl", hash = "sha256:401eb9fe58cc86015ac0a6682dcaf1b4bcea32c428969853d02171d6650d1774", size = 14217, upload-time = "2026-10-29T17:37:30.465Z" },
]
[[package]]
name = "opentelemetry-proto"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/9c/1aa4ce60e9bc72af20dc49c92cc0566825092d92b3d6a277439fb5fb7c01/opentelemetry_proto-1.45.2.tar.gz", hash = "sha256:bd8d23cf770605b0af9b7be2071d26a464fea289d35429abfd2a66226114ad64", size = 46486, upload-time = "2026-10-29T17:29:53.007Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/0d/a0b8f2b9efe2ec1abdf639d3696db9b1563ebeb11d91aac08ba7341e9122/opentelemetry_proto-1.45.2-py3-none-any.whl", hash = "sha256:03a7ca1b43f89fd5b965d5975902bb2515c0c86e2428b456836e9a6149c6235a", size = 72484, upload-time = "2026-10-29T17:29:31.403Z" },
]
[[package]]
name = "opentelemetry-sdk"
-version = "1.45.1"
+version = "1.45.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/1a/74a21d4d6969fb8b432ac397e66112a8167a6deb267ea4559dbaf2fe91ef/opentelemetry_sdk-1.45.2.tar.gz", hash = "sha256:7eec4de13dd21ff3a1670dcb0e02079e692736f94d733dfdce5af461d884248d", size = 221705, upload-time = "2026-10-29T17:29:54.685Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/a5/9a3b793ef48245f2336a16beb2324c2397dbb3f987cd68942f77cca06156/opentelemetry_sdk-1.45.2-py3-none-any.whl", hash = "sha256:ff974ea70ee4f1d51e2ec152db37c2b626c7bf82bb3f7d86114fd8704e7199c6", size = 141756, upload-time = "2026-10-29T17:29:33.722Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/3f/50f95972b50e8e353f5b9342034c9cd17730f1956f009ce6a3fbbd89f9ce/opentelemetry_semantic_conventions-0.66b2.tar.gz", hash = "sha256:7da5f1a5dc4383eb66711d4d3d3e71824d9403ac1559fea021cc103c1a4c4976", size = 150246, upload-time = "2026-10-29T17:29:55.682Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" },
+ { url = "https://files.pythonhosted.org/packages/67/bd/6bf33ed31521c565931c037e9e81bac47c1acb000e850c6e24220e2a0c00/opentelemetry_semantic_conventions-0.66b2-py3-none-any.whl", hash = "sha256:4cbe61e5f9275d57ab31708446e7938ef4291ebbe6a7fca6f7a629c5acae87d8", size = 206280, upload-time = "2026-10-29T17:29:35.062Z" },
]
[[package]]
name = "opentelemetry-util-http"
-version = "0.66b1"
+version = "0.66b2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7c/b5/df4b61da899f6ebdffdbdf0c8b0f3189ee57151694ccd5b7d50ee2906241/opentelemetry_util_http-0.66b1.tar.gz", hash = "sha256:047dea1a628031f857a5a32261dc0e955bc162d39993ed1cffb8f2cff5ba8a62", size = 11586, upload-time = "2026-10-06T17:36:46.572Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/57/33566966a7bced4d02fbc672f92029184e4a743f5b531e44b4c0dc4c999b/opentelemetry_util_http-0.66b2.tar.gz", hash = "sha256:7ba066ea609202e474679ddf1d0f753a1e6619bd206b36afaecba4bbb86e25d0", size = 11587, upload-time = "2026-10-29T17:38:56.043Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/9b/c77ecaea79ba0de1a11e7f06a7f5eea7043ec23f1860dcf5f03536698e4c/opentelemetry_util_http-0.66b1-py3-none-any.whl", hash = "sha256:8f443d7abcaf29c4a07b373bbd31b5b39132c0ed3c27d015a59dc0323d5b1c58", size = 8387, upload-time = "2026-10-06T17:36:06.984Z" },
+ { url = "https://files.pythonhosted.org/packages/18/8d/39885564eefa43f6181acfa3d698af2f6b5fe3e64c86dce3f1b955f6397f/opentelemetry_util_http-0.66b2-py3-none-any.whl", hash = "sha256:c4bb5d6744e60353bcaa3e325bc529a1515346b51cb4d9c8e5b730579337e06d", size = 8383, upload-time = "2026-10-29T17:38:07.462Z" },
]
[[package]]
@@ -2243,6 +2271,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
+[[package]]
+name = "prometheus-client"
+version = "0.26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
+]
+
[[package]]
name = "prompt-toolkit"
version = "3.0.52"