From 55441ac4be0b6c2807bb08805fce0b8c09c0765a Mon Sep 17 00:00:00 2001 From: Jeff Peng Date: Sat, 31 Oct 2026 15:36:01 +0800 Subject: [PATCH] Validate IRAOP research evidence and add opt-in search tool compatibility Signed-off-by: Jeff Peng --- docs/_static/install-builder.mjs | 9 + docs/installation.md | 55 ++++ docs/installation_builder.md | 2 + .../files/agent-documentation.tar.gz | Bin 35326 -> 36773 bytes helm/mosaic-stack/files/llm-compat-server.mjs | 228 +++++++++++++++++ .../openclaw-seed/extensions/iraop/index.ts | 48 +++- helm/mosaic-stack/templates/llm-compat.yaml | 70 ++---- helm/mosaic-stack/tests/chart-render.test.mjs | 41 ++- .../tests/iraop-response.test.mjs | 165 ++++++++++++ helm/mosaic-stack/tests/llm-compat.test.mjs | 238 ++++++++++++++++++ helm/mosaic-stack/values.yaml | 5 + tests/docs/install-builder.test.mjs | 11 + 12 files changed, 825 insertions(+), 47 deletions(-) create mode 100644 helm/mosaic-stack/files/llm-compat-server.mjs create mode 100644 helm/mosaic-stack/tests/iraop-response.test.mjs create mode 100644 helm/mosaic-stack/tests/llm-compat.test.mjs diff --git a/docs/_static/install-builder.mjs b/docs/_static/install-builder.mjs index 8540a745..1c968547 100644 --- a/docs/_static/install-builder.mjs +++ b/docs/_static/install-builder.mjs @@ -48,6 +48,8 @@ export function buildCommand(state) { } for (const name of ['ui', 'execution', 'observability', ...Object.keys(modules).filter(name => !observabilityServices.includes(name))]) set(`modules.${name}.enabled`, ['ui', 'execution'].includes(name) || enabled.has(name)); set('modules.bcm.redfish.enabled', enabled.has('bcm') && !!state.redfish); + set('llm.requestCompatibility.researchToolCalls.enabled', enabled.has('research') && !!state.researchToolCalls); + if (enabled.has('research') && state.researchToolCalls) set('llm.requestCompatibility.enabled', true); for (const service of ['prometheus', 'alertmanager']) { set(`observability.${service}Url`, enabled.has(service) ? value(`${service}Url`) : '', true); } @@ -115,12 +117,17 @@ if (root) { const redfishOptions = document.createElement('div'); redfishOptions.className = 'builder-suboptions'; const redfishLabel = document.createElement('label'); const redfishInput = document.createElement('input'); redfishInput.type = 'checkbox'; redfishInput.name = 'redfish'; redfishInput.addEventListener('change', () => {state.redfish = redfishInput.checked; update(false);}); redfishLabel.append(redfishInput, document.createTextNode('Read-only Redfish')); redfishOptions.append(redfishLabel); + const researchOptions = document.createElement('div'); researchOptions.className = 'builder-suboptions'; + const researchLabel = document.createElement('label'); const researchInput = document.createElement('input'); researchInput.type = 'checkbox'; researchInput.name = 'researchToolCalls'; + researchInput.addEventListener('change', () => {state.researchToolCalls = researchInput.checked; update(false);}); researchLabel.append(researchInput, document.createTextNode('Search tool compatibility workaround')); researchOptions.append(researchLabel); for (const [name, label] of Object.entries(modules)) { const wrapper = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.name = name; input.addEventListener('change', () => {const requested = new Set(state.modules); input.checked ? requested.add(name) : requested.delete(name); state.modules = [...requested]; update(true);}); wrapper.append(input, document.createTextNode(label)); inputs.set(name, input); if (name === 'bcm') { const block = document.createElement('div'); block.className = 'builder-module'; block.append(wrapper, redfishOptions); buttons.append(block); + } else if (name === 'research') { + const block = document.createElement('div'); block.className = 'builder-module'; block.append(wrapper, researchOptions); buttons.append(block); } else buttons.append(wrapper); } moduleGroup.append(buttons); form.append(moduleGroup); @@ -142,6 +149,7 @@ if (root) { function update(renderSettings) { const enabled = enabledModules(state); redfishOptions.hidden = !enabled.has('bcm'); redfishInput.checked = !!state.redfish; + researchOptions.hidden = !enabled.has('research'); researchInput.checked = !!state.researchToolCalls; for (const [name, input] of inputs) {input.checked = enabled.has(name); input.disabled = enabled.has(name) && !state.modules.includes(name);} if (renderSettings) { settingsBody.replaceChildren(); @@ -177,6 +185,7 @@ if (root) { if (enabled.has('bcm')) notes.push(`Create ${state.bcmSecret || fields.bcmSecret[1]} with key id_ecdsa; its SSH identity must bootstrap the read-only CMSH user.`); if (enabled.has('bcm') && state.redfish) notes.push('Redfish reads node BMCs through the BCM head using credentials stored in BCM. Resets and configuration changes are unavailable. BMC TLS certificate verification is off by default; see the Redfish installation guide to configure it.'); if (enabled.has('research')) notes.push('Create iraop-secrets with NVIDIA_API_KEY, NVIDIA_CHAT_API_KEY and IRAOP_API_KEY; prepare the corpus directory.'); + if (enabled.has('research') && state.researchToolCalls) notes.push('The search workaround is for inference backends returning named search arguments as ordinary text. Verify the affected protocol first; it does not establish answer or citation quality. Full request tracing stays disabled by default.'); if (enabled.has('webSearch')) notes.push('Web search uses DuckDuckGo without an API key; the OpenClaw pod needs HTTPS egress to duckduckgo.com. Clear it for air-gapped installations.'); if (state.inference !== 'external') notes.push('Verify GPU, memory and model cache requirements in the on-prem inference section.'); prerequisites.textContent = notes.join(' '); code.textContent = buildCommand(state); status.textContent = ''; diff --git a/docs/installation.md b/docs/installation.md index 459f0600..4d7dacc5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -621,6 +621,61 @@ helm upgrade mosaic "$MOSAIC_CHART" \ unset NVIDIA_API_KEY IRAOP_API_KEY ``` +`iraop_query` requires a nonempty answer and at least one document reference with +a filename and positive page number. A response without that evidence returns +`status: "incomplete"` and an error event in the Research panel. Its unverified +answer is withheld from model-visible content; the full original MCP response +remains in the tool result's `details` in the OpenClaw session transcript. The +chat history API may omit `details`. OpenClaw assigns its transport-level +`isError` flag independently, so `isError: false` does not mean research passed. + +Missing sources can reflect an agent or model/tool execution problem even when +the PDF is indexed and direct search works. Check collection metadata, the +original PDF, actual search execution and citations separately. A source +reference alone does not establish that every claim or inline citation is +correct, or that a proposed cause matches the current cluster. + +#### Optional research tool compatibility + +Some inference configurations return a named search tool's JSON arguments as +ordinary text, without an OpenAI `tool_calls` envelope. The research agent then +never executes the search. An opt-in workaround is available in the existing +LLM compatibility proxy: + +```bash +helm upgrade mosaic "$MOSAIC_CHART" \ + --devel -n mosaic --reuse-values \ + --set modules.research.enabled=true \ + --set llm.requestCompatibility.enabled=true \ + --set llm.requestCompatibility.researchToolCalls.enabled=true \ + --wait --timeout 12m +``` + +This setting is disabled by default. For an initial research request exposing +`task` and the supported query-only `opensearch_search` schema, it explicitly +selects that read-only search and disables thinking for this first generation. +It wraps only a complete, validated JSON argument object into the named tool +envelope; the original research agent must execute the actual search. Proper +upstream tool calls pass through. Explicit selection of another tool, synthesis +after a tool result, and unrelated OpenClaw requests retain their existing +behavior. Malformed or unfinished arguments return an error, never a fabricated +search result. Collection scope remains enforced by the research service. + +Use this workaround only after reproducing the protocol problem. For example, +the installed vLLM `Qwen3CoderToolParser` may declare named/required tool calls +unsupported; the checkpoint's documented parser configuration should also be +checked. This proxy setting does not change or restart the model server, and +does not certify document relevance, citation accuracy or general answer quality. + +For a controlled validation, additionally set +`llm.requestCompatibility.researchToolCalls.auditEnabled=true`. Full research +request bodies, raw upstream response bytes and returned response bytes are +saved as Base64 in `/audit/records` inside the proxy Pod. Authentication/cookie +headers are excluded. These traces can contain private prompts and documents; +export them to the authorized evidence location before replacing the Pod. +The volume is temporary and limited to 128 MiB; audit write failures fail the +request. Disable this tracing setting after the required evidence is exported. + ### Hardware Agent The Hardware Agent reuses the Base Command Manager head address and `bcm-host-ssh-key` configured above. The chart exposes the required Base Command Manager lookup inside the cluster and generates its internal credentials. It uses NVDebug evidence and generic analysis. diff --git a/docs/installation_builder.md b/docs/installation_builder.md index 602765df..a72a1029 100644 --- a/docs/installation_builder.md +++ b/docs/installation_builder.md @@ -6,6 +6,8 @@ CPU, RAM, GPUs and disk update automatically with your selections. Values come f Select Base Command Manager to expose the optional **Read-only Redfish** checkbox inside its module block. See the [Redfish installation guide](installation.md#read-only-redfish) for BMC credential resolution and TLS verification settings. +Select Research to expose the optional **Search tool compatibility workaround** checkbox. Leave it off unless the inference backend returns named search arguments as ordinary text and the agent fails to execute the search. See [research evidence validation](installation.md#research) and [the compatibility guide](installation.md#optional-research-tool-compatibility) for the supported schema and verification steps. The builder does not enable full request-body tracing; temporary tracing requires separate controlled configuration and export. + ```{raw} html
``` diff --git a/helm/mosaic-stack/files/agent-documentation.tar.gz b/helm/mosaic-stack/files/agent-documentation.tar.gz index 4abee1d0111510a18909e210cb885c9d9ded728a..31c942640117bc758625c9d59b7ad07025a2db24 100644 GIT binary patch delta 33318 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 delta 31827 zcmV(&K;gfop921q0+1hn?4nFhQxq++Oo==MC08xB7BdMX$+Q3@Diff@vNfxJ%>$fu z{>)nEyj(xY+26iIL;^`ta@ne?ZhNLn1dtim9Xs~*+lM<_PhaeGMuXq|djGA`e~%tK z;D71AxqsIlJY0KZ-|6>XuHV1^yJ+=ypW(k*HBC$E@^}9||N9~Z+S-l2O_8G_0ke_D zMFEMk3;{C%0o{{80xAN(%#&;a9RX02iUOtqOp`GKumQ`n;sd?`1lzy<^^>FpCjoPl z%mr8hcatRsiW5)Ku6s)U9uFX{@bB~u?>UIEGyB^o@Dgzt>|YB!L=YP=UdFw6Vmllt%dDVwPecN%?Lqc>_y~VR3zhm))FBV9Urk(I8K!I`` zJGhl!Kyu06r(goY1Vs>LL9<{1mc-V7fa8(!rRa7B+Ft3nzL)boDav9pk`5RipM{Fu)5>cTV=dJ)sw$zS=&rcY67`*vLw@jRd11 zm7Qb?xx0>^W|!Fzn-OhiGr-g$tns{IH{)jAXV8sR4}00 zK?XP#J=PRmw-7Ahs#?W1uN20$ehKUjMwp#AB)#Y-jY=MLwYyJF?kbtmppsc>c}Z#qWfFDtlRfR_4d zs2cOo>mK%PBiac}wAgfcfTUMy562=j%?5^9%7u478@&7Y>9=Q7BaR@bU^f&_w$K} zdvxZVN&NImwOMKBBTtktBju}1q{oo_VNPybI66~fy8GU_KUVA&mP&70Z1)hqg5%|% zrmshx>Y^)pSfHcX18?1Tq?31rT=(df8&?_RIa&ytIQ==49|j?RN7$OAq?Xt_(Ryd~ z&E37-?VXps9V~#OVY{=Y9Dt^MZz`#k=?Mgz=l zXczf@!2qM@R;)vRDlAqvO13bsGC zsz_-YW-&L+)*&-4tBKcl>-9~T9?ki5lY;?#yVdJG??}sHy@!2LtjIjyv&I&dt0?J@ zV$TxkZkoy{fkL%ct-btMVHYnB7<0}bHHZNb#%K3uT7!hTU(ntqPrSS%W? z{;0Z$;(oM$Jjld0F(m1IS(Lmj(!fHqshLv6>7A>jDaPD%FepJ6^gIdCG<|rW#J~G& zo8D;MW+6$^vm%Yvp`Ea4*7<+b#BJv7PP9eT0OJs=pUxuw?tUXi${r^KM5z5A1WKMyi#z`G%Ri4|ME@x)_1NERKY; zyd0@_`XY0tZrHuYcCg$W>KM+{rdhXBrX{0oD=4xR%Nhyl6H#Dep* zF;!ZJan?77JRi@Na5_8A%qwSArk=%|os%Rp(Hpv}%z~UkMKThrVS`qZ=2(N%VoI-< zNwocfMq%shT!{%N25dBOzB5xVw0-pJh>YfcJA-dZdEfK29K3^kCWqt6i|uGw6mMsf zN<2xnBIu;j@qr46x#C$njkJiZn&WkAd_`g<5g4Xk@+LugPpgWw@Ik~ru&Nkd!sRQQ zM{DDn#{AFe^geFMSKhZQZqaC(zQu9P`k6!mMB9O9nb_hLua6$gpnf$-vWeiqAr z0xIA;MeXE)LxArvdCIP`IOa*HgCLG!1&-F%S1ZTzIV=yqpd7zvm1)Y(1QdePaIIj*jB zp3&mGORsnc9HVm-e;+SLOLu}nF4;#N%97jxyc2pxh0qch(+5PK zNd!9&WD(M&*o8>HKCKU5J7n_?F|X_IPNUcommPb)9rFl9m&Z}Ew*F;jmHyv<8YBh| zW=Ihs(0W(Qh6Ay($+1tNaw~b=??l_qUf8=RN*dL0*lP9O<`cQ`D+!|!3Cul#f#b+} zBPYD{?580Pd7VJ##eo(z7xu`P3BTU9rD9Kr`<=MTg^76ighO6%Y+L>b2XV-?hE#)saqN*{!(H)zRR*nFer0H z@Y>uc1F^8#DvM#}`E)+*NB|rA;hfXTOSIs4i51&05U&Ku z3L~0FT3#bU(S<0};9fMDGu@)V?&>e@%AV_g&=ua5Cp#C@ z(Qu_D?x@OHa=#7rE*?S6@F@hwGtzTwDBRPce(jCAAgkrX{wLRD*mcF{S ze*eM4M_+#RhxAk><^gdsqnf|MjY*tne&qPRN~8;1M=Zrh^j|)IxqRT;afiTr8RuJa zzU#*3=0=FG1{va_#ag8l5Cz*4u)`+v#A<3TE?| z;Z>_)Et&I$gO~AtY+$IobhWum`xE@LzOLa0V@kffbCWvzwuJD7!q5Z$CMNC?J#C=}Zgrj2PYP;8vp-AM`9yH@-{i4bI<& zj&^Gut^KU7nkTUT>geFrQ4|;RqUNtU8|`)N7wWg=Hi^%`_C-eTst+x=!ju3>cpabH zC1jDCCV_NFYZp;X2)b3k(Z)>7SrV@F#Q0-oH`5S*j;B#^iHP*jZe+T?$m-6XN3w53 zQVYj{=%t$mttvkW=-VItKIYgGCI#JSkW#u0RTZ1xe? ziM882WaA1(ZV_F5iK2_?bkZdn$B48gL2#2bJJ%Ruja(P8Og84h(<|jDigWxcW7&L} zA^HG+R1?P>Wx3g(l|zZQ?BdUjuR3cFJFE0FKZUPABVtLg5kwE^yHWa{KSyiysp^5y zJZW+d@1kYMiJQOw5&xVZw&X)J9ai*3RSi#M=T2y7v*GjubTPbTlBJ28Stp(3p<(E9 zt;^OGT7u8D)*>K`C4fnSaXF2G?p&Bs9;~i^S?m~QN6c0&P?b{wl?Gvopx0o{DW_Lg?$D`< z_#jjf1G|M2N1dt4q*9Kp<1p#E%t7}Q`iiYD)opo-EiqYTXT$vbVj7J|tF>DoSxwr1 zwtTFMA#Fv??G_W^jA`0-Ym%zH=|(xpEV99Ahq6Ufv-77TIPUjO_V!+!JlX2)oV+^R z+g$z#Qe19q;>G^c9eQ7$?2d_V5N(a)`<;hzHJfB*d^wDXuukK2ZT-|WvAy@IM~$4k z*m}A3jQ)MTv-R}k`F`(cbKWR%%?q@Dswdy={Q2Zy>*)FBau-Y2?Nrr8Hy@m2{Xvzw zu3qf-wsyBqwx4eu9&Iic{hVg0!P4E;KRo#A(S!S6KKjG`)%Eo+SG%E2+KvmIw*K4Y zRtu)LC@!l>vzW!QpG=pzR7ILi7LMfmKFMSsBf8N5_m#roT=DWbA}Ivle7euR@S47CnvWlpOaQ*4?qMCk}UGY6fWVk7^7!mWkhKAin zEk(;YO>ssGV`it{S1>UJi?I~_6tP#rnVTgzU6)^ZvolOC-zn1B^kQ1P&6GjLu~{l( z1YnN+O`z8&-m4}E({%>HL3tH_(><{%{tis@Djksk5!tKhWI{J1C5bz;{gM;qA zX{TcIHd6WYG4h3R6nJgWKkRFw6{zhunIB@Zm=nZT##}y-UIsTZJ7@HNd+42UH)eQB zc4g@9HD4(DdbQhD%T+K!pLb`8gz7YHT^G!6X?oGV*jQWwpHGIf9H>EFvAwP1xi!LE zAo^i55M<=yoH?TmnTu>QRaT`|L8vwvf^hkQj$?EFvGo-9>cosRmPaa1Lv_4DPSWL5 zYFoM!a!*GcJRttt(WU%7={ct`kPSat>I6-jVcI2(iX*mmCpaak9?G5i4 zZ`hn;I=)jnryU4Tn=S-zp?y4Jzjol*dQ}eRPq1B+OSinvvfBL1d_m3+%DrN?JUIcI z*E1TtX$euYyrUM{xkYnN_}s!<9t;ANZg~;`GuJ#qKfUNrZuyme_W_6Bj_tx)>Vpug za3^ivc>65059ZRVE{FlkTD~MA+`NV{hV$YOTsQVJBCMv_*RnEiefnJT-)?=5q{Ix_ zi?=?A6w@)yBkxzYK0AdijcKdlLG(C{Ri2fv(rGbrCzw?T`O_4jwe?Y}B|gO9m^I2T zav2aOe$gA}PQ0-LQ2_c#B3g ziaJ6=sirP1&H`(kUzqftx+}OLdFh;%3B#`%E^qvX+}2jh2>NKJcXV=axc}mSWYca# zjwIqS8^;YJ0%^lbVHn4fXeg5M{#P@OVM=GP2FRmTXSrd2XkZu`G9M&asb=hCE6osM zc|)&?SbZK6WTG?^lRCKe6$0D%eY!>#=eyMMu_#zfjnxR(_pz*)c+*MHB)!h7!Fd&H z{!|?QWtL}wLj0J?fI7ZOX^x^_Qf>CFedL;6v#L7Ik6G1g*Yzy-LRo?}b`c3K_q?Wt zy1<1_GpWIUpTv}v4v4Yf8s>qu7Nr+D`CoQ8?t_oht6i32rWuJzEp{3phF~1lT;-+$_i#N!1{%MnfmjtjvH44Cr7mew+=DXD^vb{ zsERR+37gA5b~ty>^so=hB&xjv^MXwy+Jz_G zJH5r#ThZZaDYY}T>b7avO<}3+2%>TEh#GV_MtTCLL7xSwA0DgYFdLlZwCD94+_})h zFWmiqLuZ?x|EG+Z{;%}kd?NqnkN;kMcz^vtJ^p)bZFPP1xA^bR@!!|~LU(ZQ;OU>@ zZ|P3lE+$tcY_mtCb!|lMV|2H_60NVUKeA}p?ft`p{lhKzNTYv^w!Zy#cW-x#F5XVG z1@{Wx;b=Obt_(WF)4pl>o-~mo-eqonmIJ+iLvlRgb#_5~L$_yiJ^aMAco!VeC?lj> ztT(yH$L~4vBU3vCa!SOo@ggnX4ggf7C>Vph1UJ9xL^}g@z89QtM*L78DFu#+q;rjZ z#o(%Jk}}%T^P?Ag93)IblH1=Wr?C?YuvTV~Ii6kmSQ;F`P6|n4h&-CvW%pe0J>p7# z@I7kjnBA3j^b)bwKU5B7rER@TQgs7M@|z2rMp%P5D%i&1!>Jh?WWa{Wnu#}w?ko9Dr;cC*MPIFc1tJuRz)%-j zy9s3hZzt-|;n4yDjSd6d#JFoKc#g8NEXvA4n_1m2Vd!-fV zK`#ode$Z2=iGjK`=K)u9X^w`$;-e*r(=+BtDqepWV+Fk;N+4S%7`(&$spf=vmQ(w} zv}rd(f~1YJeMlq`F`nT2BqVL>Jme*TE=HTl60WHFV)cpc-RlWQt)l;b{NMk#_JhAX ziw-j6@k6hel}Po~k!`X0y1I8ymo7IH6yR^rQAv!KtCLVODD&dRG*w~~uZX6HIv@_v zl2}t#FvBsi^EexD0DLO2AMDn|*R(NE*SFq0(-bFLA8@1%rU)fj_f%K6*Zx|9@uiD(RKBl3|)KcjsuRGvX4)u`n?UYa!9(ZR+A$c#cgs*SM(N zE?s#oJK?#V@GU>N(m0x32{Wm@txvl&2p*n06Curq)AMH&LDQ;#n7g^`SZ=+=${)N8 zNHEh?j%{XwBN&pCH-T`TI#x;Z%7g;t`e2a>^poKRF{ceXH0sF>DaS7hWAdpJEtmRn z6ofDU%EjF>l9qcZ056e5gg8%-vPzKQC5JAz7#9v!X9IF1k}e@R%^2^F*-G}CM)}G5 z-<+EMS@plQwe{71wbgn3@5{B{@PGgQ_`kx`pE|yAD@?8zVVLLObxnVeRAIYWG5fsu^&rO>>&p6@^&&lI`@m=&$&h2j*R`L}5X)$5AX3BE#cZ*PRmPgx&C8xGC zj}JT#g=d(PH+TAyO>T>QHlgNqpnHg^4l?3QvD}Vp@2kvzT{pp<=(mh+H7zWDOPyuB z+zvgMb6@3BmuptxF$6a+%n!%+oKGC&u)TC%?=5Q7rE8j}>$l|Ee~9E-r=ne(SZfk# z5RFlbA^W)@WS=dj-a9!8HU6aLTt_&UI`6f^r0MiY^cpPE0iNWl2CYSwA74!n;$f}4UF}}(>{O&>Xa}Lf} z*tLMoYqA;-S{N{rhy~emYmBSZ0evD9%jc|rt%ENLC~S3Uzr*N7W74^@ zQEg0xd{-Ninn_Yk&}aSu6zG6D@=7<}x-^C7*}*Ga<1Zq&2)oueX}_+Rz|A2VY5{WL zSB;Qc0Bxq7TEMkoeDcuSNa8~19LZ1)nn0mP|KS3h|HjDQfAomdUpQb88^5d}e$1K= zh%+lf{PF6W4)fb)#=ZPooZsIT=l6L--NNv}>z?_zP`poxh5P6LyI(PrG$HH(xU(T5 zO927xlWilF0WXsSB)tJ1lffh=0cMlvBy<6pvt1>c0u(IH8E1TFa7t+vsh}JBMWjx) z@3NB%C`18XlTau`fA2XWX(L*@zqb12dhk1`b4O6~s*UKYwLd7fXJW*JTDpd|u4^D$ zh>?;ACySYQlo|uS4ye8b{(Q|^CmqLY4TYT^zJ6x?mx3m?5kWxEblTK=K$L9<^}rpkW*f7M;BsO3e_-BD$s^wVI84bhq0J%^ z9F4c;ZSt<3C*xVW3x_K$yOIbYKaePqz$4-6h&W6}6K^@1p4F$8dMxzx1-dT^ge=~7 z9PC|5!T@i!ZQF9ysw2-KD^CK~*@dA4a>8(+M9S&lATOaU&lano>)g*lzAAreJB>sheslK=`Vh;9 zl@JzD%|IL~8)VGXVugJ2(s9jW7x?OG{ysG$)jWP&QkveG;a{!R(^O{KS?RKzx2_)J z)#l4ce;0;R^%gK1{+1fkPp(W4Z3o=VP+~ZHtB!$?1i)r4%D58qg$~kH;!uH!_mCBW z6jmdw4&6_kXfK1H6b8>tFC3JDnmy`2Y6E<~C6W@z0&-ayfnq~Bf>c<7ttoRtA$Xs? zHlIEPAxE zqP!BjrZY|>)*V#rgao_(`&@dAwrwhk-Yu2ZNq(qY}=hM-_c2cLIdejmR zkre{2sSq%w%4(@|8H*Zbi7Vz{v4)-Kdz}Us;i#P@Cn@-fJj8v>0VtzrYiR*z8V+J4 zfAqMqjAB&nH>MrziWcUo?Lg<|GV*FCKOYCt@R={G!=`?BnpxLoHWo7VEw(>NF*CJ+ zi@v4S?$DfdbBu}TkD3DSt#-~S0g-0jO~BC-p*e@)x2$YZ*f$A5BgClASVw-HV;Fh* z0_VD4xj1HkDHVx-J_M;F#c<@Cq!VYo!js<`4W^epq^$)bsXuKEH(wkoZ-`l1B zGLm099yF$*@vk1Ne@4V_EG>gsW zB514ByBMome?x+Fe~~?0u3LY9W^^iS>RH{=ce`n{8OHL4Q?j{qr@jtLH-{BEL!w6_ z+AH4XxM3^yB3YEo3z^H@+2&_me+{|D5bC}C?{@1^(`Y-iDEx!3?~vx$neo!vTs~$| zb-}=>V4`$OhS~E4>h33VCh%hNIYQxMmhMF>cbZ;HbU9XTJfH(>EhU#S(&H%gERVTC z-9Uaw^r$y?c}k7ad@c~e|_<#a8kN2 z*B(OOm`$BhvNg=gsTKpeIpi^nDq?G7@TR*he4ZqY57A{LFx=?HnE%nqH0cc@#C`?}h{WHBUk_afiJN`^gL1dIQ69Qvrf1=zJ>$;eU+YKePXW}-haj%;?n`3GE`Yl}T+2Pi=v_>Cs z)0h|QCYtN&L(B8cKixXo>g~Te+}=5Pwfl5)**9|2qep)}*tzjp)M|OT^@1Ma*vnd? ziOt`{Pb8Vxdk0&+-uL^5Pk&~DiOw>B^s6VBxTLtHJM$x5YI;6Me~_CW;oqK)^6Okf z^FGawxq$Q?Ii3OGEq&yRPf7I@D2!KmxK?%I`t;V0t|nP?b0EObaoyO^)}m6lc|D=r zoE9;^{mPd7$|c^^r38+Oq~0XWryUkeQr9~SlsnaEBQBqXVj3di;?G#E6?RmRiD*YS zac;eO(6i5T_jlLve^{V7=EdfLTjDv8vw01H-7)p=x*;aK4XW!~?5?z1oL$*W1uSTT z%hKNrp`TpGU*`Ia?O)#&>A>Ps2y`p|s{*4Yfuf-d{74<<%)u3bNB~yVg=Yp3^Q>RT zt3SheUU2RQ2l_UVTax+NFHN%cj!g7?mX@%LzAK@17yhzxe?|hv*OUGO2c`~){g?EB zc*=B8;+f%ZF7oLx_~>Id%(Yg3g~z&c30<3wiFzg14CY!{E+0qt?!C^lclYk`0V*pl zDJmgrT3b1Mjp*#&J4f2PClPcEsMV1aZvwRKYsO^kd7F>PoLLlMx_%@Uie-c?|C2F{{gYeB=`+UNm8S%wR;(|q@ zJDw`>B%X=z^cQM8ezAREUg%l?7I8#%T$`iao&=hH4Khs6m+=-Rh?b8x;*)09eD$## z{GH~c34|k&3Q_tr)?^kA4{W!zKKkxK)U(SI5vUm+LGeGg9OfTx*ImDU-!z&>yzO)K zf8uE`q=1_iejVnc+`x-ewhBDpo!~l?b$rpE^kfxrKUMzE$YP9|?=6kwz*K$AhzC5} z@PIX;Dz1OWqyEIbB#XC!2(k5tk2b=(o&7;}_NkvO*rhzLHlpJt$QQ~`mfpCTT>3F1 zT*LBHeP9j}R72eNFyLrs{`I5%?{;1;e+D20lf5bUh+T#jgwvn+cHk*?n<4-GDc z({cV+K3E$8^Oy^YdcOGXmg0KKtHBSAHAX4lZa&-EJnHC06%;L7Qew+h|?J=Sc|{g=eG#{UKpDp@qP{h5U~q7kA$wBf0mi7 zFejsD;NRklMAVQ3io1B5rWXl^cqG#@9asE4PLBmFv}ggVEVTETDQ!NsXM0OfGEjAK z-Yxey4A5iWmxk{}Nk8rM(WeR*{5BWgf+tS~F@Ss7vY_EGXodbeG(yx3IX}S(5O*fD zc)5Hv@C3byU$MLr!%mcpw3ZLze?VCr2%=_Gi{c`a;HGs~Rdg@|b05>m$5YaXk9lu# z(>1b8!g!;63_!|Cqkl9&z&QtfVKEy|ORA=v(wa_ZQvsWFF(oUGsB6U2ZfA~UL8T$!ja-AAmIMn>#rD#cabGDFU!`Z6zt2%be;yJ8B z*R#`*nGTEM?QHU9KDRhLc6!!$KN?P)3QaEFjSn1iF%X9wxsC@OT0CJjuVMhA#=TJ{v%gO$9v1_tD*czBvuMIF*py;tg70Q23e`?~lSZ`}33+7~E zV6_k%S|9P3i(OAKi@~B{y_l-tjwU-{dUrj3qAiYFo^u^dJP4m~d}WHndqY(?oQ*fq zoCI^2nvRkYt48xZAKV<;J=4hXyh?sL$>+GV9EI^qHYaIaW-cP)lVK3cp9 zceQXx+bs?tQ!|sJe;sF~1M0e=FE@vd-!7_>=9lI-392=b@9@>jttO$5TWJvc7(dRx z&WE)Y#=Y=j7jJX{pFXPOSGfD9MZXw!p`YgiD_q^JAAMV2<8Sja8n@NRN5ZZ6__ym! zfp!AnKmC;P=CgMdM;tWJ!9EzCEQDO;N`Ok7ausUy5qm!Pe{f$TC6RzIzrkAQpz6rV zHb_zIR5HdDfJB=Nr8i)c?mchyY_6hZaX@Vsnsjr)E5=kXr5;Ru!J~D373JloT2sq^ zLowx#tw{V&QL^@PSegGTmCL-zD$XDQM4&ly0N7#-jZ(~nI95KIZ{~})yNe;Pfo{K` zme|;-8LTFAf6oWJXz48&ilheDFq>Aj4Ehu)Wm=pa#l7B{1(zebV84YG6hj)9DDt5N zEaFo-aU0R30E=R5^>)jp`HHQ$&1tJplaZCzCMg*)&HBbn%y@0?GlQ7M(IaAsflXWU zg;ju)67e!-R;rZ6rPuHU+8Z!lG0@&v6d6JqqHI|`1`?k-s6CqDT zYn#7V-C&}U`A9ISlo702oO!I$xESDIw#`^&#IE$dKEz3gXJ?q6+1Js85 z;_pFQe`86pVKrN1M8WokL7_j>;K;21R?sTw1s9GJ7Wj)Ik7UYmD5B=zu;#*wypTEjgc6KK;B-H?Tt;;HUcCeAvBI<1uS9aeG_Jg$f!{@V83ke(U9M2QCO%wuM#_4we(+LNz8>0=5y0l)p%> z)ENGk4)=_;dyXm|Pg@Mer9j>NtVHQDFzt$m*}Qffi*|%;*Dx+Dk)d^3jW6k@OY$GR ze|gt-JbAR5tcWM2s%`&c8UV4Wr_cT*1D_(NIi&}ut^2E*WvJd1u5v0W&m`p%HVuqe zZZcac=hNW?9!9TcXN7aus9%8{8Omr8DC;-66YXOYl4mbxNuI3#5T+`PA9PB z=^!qGL#+=paq1JZIAAzrbtwjXBxwk`?uGFcg3B|sEGfh~Ds--Y&Ua4KfFx{mqJQ>gr&3QC-yPt(cYlOGaqm3_9?{Nh-ssK#p6{+< zA|Er4Z;g z+2}MINNOn89IqxQlh*dBk9uYkSL`PMyp#|JuJ+Y#g~-7T*4!z0?05QzhzdP^2Nj@iexmE06=hMM~~x2_X6%5<(b6FU{SLQk3?;p`Y7qo z!~AXLA`X$%$BEQf;Z~*IMXAd^NI6M~#T#3lN@~pMTF8#DklOc-YZ^$aR$C79wp!Wh zCl;+hygICf2esi`5jzoEf6);;?fFZOIW8;788;4FoI_?>3O+8oRZeD=CK*n(#$zBs zg%uYB$abMwF2BIP&0&74Qoeh*wSQngkurA|W}T`ci2(y3W&VA1e|=@i9{1AO(WgR` z`wmY~)AUu(6#xFHD`;2UyiAKpEVEwzc%@!mv+ZvP|Ew$`f>09cy(P=%kb6pQ(o-q7UNo|4JY$vk0ewx4K~La z+07Oy?nSK%+t`!pf6xKqhlAg(B?RZw$|wHT!ZCKIR^az(b~>Y58Fs_5*59yGN4*WN zNQCKpR*kL+cAa7Iov^e$I*2=-;ee+oZx{ThXh#NShl=vH$u z$(XBwH`nS)dYM4OklUO<)Mj8_n{&4n)Y)`U+2o6oL!oW;=tl*<+ArPGx;ECoF-}JC|b|m z13OZz8xmbof3M>iU4mt$J~z1(SV&^wOI0oxUYM|gDjOj7l1u`Tpgof0^A`ViUI;Wj z%exA3&EpUExYPvnT5TH(up1Y)z~cL~X6oZGux}4@Ktn;aG$1);SS(=T=)#a#YqAXm zGkcc;bD=2B372XOw1N>cpu(q$E#XvRnuCZZol*M!e^8{wP#Jzm60AXxCTq})E3JliS@_S<^DEb6horx$Xj+2Mwe12TL3i+~G9L1b6DGN=(w z|GzjRkv^BYT`YQ@g4!cN} zy27f0e`JyiEIY#)q+0WrWkzKws`{F1^QpKlqjv!VVmDG=#^ zj#AFEbG}pq7j2t^Gzc0NV$`5h!PXc@B5nI3V8GxHCDRFvaxl;Xze5}i>FEsR zf8W;W$jnZwewm-Tau@UKq)q_E9I(4i{piQ~8L1!D#c5GPhD9Mg_w~ZK>E}t8>+E$} z_d8r?OpZX%`~vtLo4Er!cNAz-3+t+y)b~42PI^0AhuhBsO-i#PJ|Vr$!`?8BM+^Ey zy&sZo>t8JS*FHt5K;#e$z4g{~8CtCee};)vtBSR-P9Mu0qB?(w=2=A4$R(oN8HJPg zZg|#4zsW>$Ys-zvWlh`U7*jOQn`{-`t(L5>u0QHL>d=kp?dn>5n$-jznP*6x?UB?6 z&bFoqFjqmmBYcR05tsa%i4R?VJe~|^=lM9{Ea$~(%7Kw*UQ0zsMes~qnDcltfBK>7 z6y-T}tloTbk{0^t0OI^g`qJ)pQ*K))}5$gFGgX->`mNBbhafSA^~+z9vc9 zWid4`Fxy)KuW$gEgyf54@jREX#?PiKk4Z@j%h&6=k#{0CfeIw{Fhq~>`)}{_2`dob zs3p`bRiLKd$(ja*O=x0t(91e}e-upd9)U_>tn_?tjiVj&K-Ejh9x~iJzXTc5LDJQs z<>sK1{<%t${z_uZ$5i2BRzEGl|w9{8y6M9z8c~He?KnzHGM zydQ#JHuv1_$&0P+=esX=B+~%a#!tb?;ze%2$_mkxn((q#mRjn=?)lxPK{Gml&9IBo zR7MRNef@7=-?cim^}yIj16qGy{rh-1Xmf6YODode^JrO(+41yhl5K=RXkAQ4akmF3 zg@r8F$#iLBshZKgQ1N6U1LV3m=83qvx@IFHJGB9`LMEWL3lXbkhM30-?Y3hN5u;$8?Og6!tqt)W0Y>;9}Lt1b(SO_z&CA6 z9EmMXU%sXO7@sm^f3R5t8>vH}>7l%N2HZDw`o8TEFr5S4AfY(ME1`D47T$N&DoqR( z4p+Hh>yoL{)2bNGrXivXxR6W|O|*__Feeu9DdT(7vUWD3rS3&B%DTX*Xr9PY zD;r;)T&873PaiM&Wm#%ROV_a&jfd|!p@Q>Tgp55lJ7QcKDQ!R}irKyGw_ogE%(GF&?N|w3GmA=vHrY5|X=V z9_x5wKc1M!e-pcFs`uUQ-d<-k@aPc=fgKjhK11C=mIPj>V`@7UyKQv1y(K(V3v$Z0 z_6D2b%+ilLbkK>K!AGsWVH%jfr7ib<08GWkQ2R({e>PU*A%_}sK?{yz2pS807w%2E znA(mII5vltm~Q}C^?DHj&BLiAp?sk)&748x~77Z2ImIdLqGuNtX^O zz{wK+;QW{Jkqb!3pV_n@e)Nt`5&$as1<%N@B>thz?hnMN#_2E#JMv#jWdI9P;Ui;7lansk#&bLq8$>G(=^3LxCh`xCmwC0an1 zD|(Svf0covRK_<1B@|wBj#1JYH4d8fwQ9ek^D981)?;2-NLal{<)xCf-yMaG zh6WRBxre@Q2YYu7K2vY+)!~bi9lD@*UT*K49PaNQZ7$nB@p^t1)VX@uk!w7$@{l4| zZrUPvcPV5^lCkjAj!u2sokOPI&EtPxy3;V_e}7+kv-GiH*GzmmhtpXN&+K66=)PH(nO(_-btN`@I)6FC5W+G$PFwnn4@vwm@*Bl&v6{zVyMi{N+4I zyoG9O5>>#(zE>ApGYUfPopX!Cso9u~yT}P3a0Mrvu&eKCETso0;c#7Bb6?1~*?e$Y zeDxs;=p%_<_D$(iC`1e=PIj-kaFL2+Onx_MXd=wT$t~%y%TmkEO1h zPH+L2W3G-g2Xj^GfAnIFdSUgHdG#-FN_j7Qfb|}j2{l-6%iQF<%!E)qk?(##*OV&` z$z7(>1-Iq4mKOGLfUKgo8W%+9XGjGfD@J)((KyIu7}K)Za=1`;PeLkcs86Imf921A zeto(<={uRFsY|WL=iPO!x>Ewnk zo$E~&%^n$FCNq8q?2};N^QmF-^=21BPIp>#-IRitWTg&0FbmC3C&Db?rfD=*4*0uM zgp(_lgXc_`+dTep39G-fu{5`ke@pEpzbdilV^MA zPnA{ouL8v|s6<{X`=M8x3$^gg>~HTz1J{(gl+G{2F`xmW99W8{6CYs4NeR}Rx`hy~ zGy!j~3Wb-NjVFe~H4t1_WzmAqU?tRKbcrWZWRQ?r%?h->fl`+~H_SU)wB7;FhUCex z!X#Juo=-(bl~d6Lx7i|Ze`yU*T}_(A#Yk)E3fN8zK}{An>6e45n;=?6+yv$>;}9=% zm}#-f%Cf&yPeYYT$N1U`iRIu8Sp~bx6JXp5>{9LyWf1kQwd)=vE7k~HL zfB!B2V^d#v3{JS>6$w70!7tUvD*gB9!2|x6{+s`IeRb`={Vm_G-G8vY{<~=Pb1c9N zo^R^@zv=#eQS-k?y7{e^hTO1&qFz{vnTXZFBK5&Sw*ze8R13VT-nebH;A3Co(<23z zP8T88IGen=8#V%zfB&^ugyfjGTkKkhkqvDv#L~h_7|SInD3(|6K>pYt9VwQvOM|Un z1mdHS_%FKw!m3@1W7MZ;P_eZchtI5NY?`l5(^!>e``{H_np-d0TK*fhpuBqPO5)Vu zH+>zGur}Lkxol8)&g3jk@DD<-BMnmc&;R&8mA`-avZoEpf6owO*NL9GBC&GG=t~^5 z%7gc6IKoyXgf$El8EJ)gC8b9D&(!?-b%f-5_ZHv{?%k7=GZwJMn%z|y1}y`7iuZzd z&7cr!1bIHAw-)!#YfPH$xx(7vLas7<+N@^z(Oypjk+gz^CD2Q}>W^jm?n87j9SvJw z5Aw?hz~$Pne@o@pv$pij*W&^aij(O#s-^!iy-a)j4Hn4E%lbTv`~ii2okkaBcIMib zd7p^UGM+5#3xVYj>Rw7$1V5&qc0z<&E5a& zU*3QC+x`D}?*H#-^XG!_j&Nl#yYSlIO*SF{H_Izxf8@-+#G1$rn_k9c$Dt<`laXbh zW>Ab2<%s09HDFqpV;oCVhp47o zB&x7LqItg@94r;}^6_PEV0$APfJ-_=w#qxWU9ng!(!6}O?TRPeKy*nM$(fKYn~uyZ zYY=EI9G6`!lc~ytG!hLXy#P3-B6|2jh6ppVe}oI!LTk_}H@xE_+DNuBlQRZwX%1Lq zD++CGvo&ZO$M~rSSvdNJfB7!Es(jKxQJ#0k=lzZtusD#51!vAi z;B*7SCy?dVD%a7WN!vAZj8+$rSu1!yPXaRCWTug6KJ<5PWr^meIpRY=v-_tUyX%uE ze}Fb{xyC_^-Jm9V;>swsO_|69oizv!!lz~i;9}GHl&rhN6BtGm7Cf-H=UyYQ(-*TA zp_Up)(rRU$G1wB@SR=d~&p)PkmZTJEwbSo>7xnbB#L#RKuGh?oUc8s zkn4BC{wyI`eApMCnil60WXaY&@uJC2bAqWJs9=}pVk36`bcCWxdnZH;4rkKWC^MFv zbr5tP%=dC_xv_~)L=5<4g3Uva1Ul{^F2b|zIZ%Y)SqB4)gAlw_n^67jmPHHyf4)Lg zaM1h`V`B|R^ReAJ9W_9@qOQw#7^;)~4(9qW%!?QyWMdDGpeN4j`!4$SQ&NLIF@ebh zhj>VHYQRGvdLUN1xpb##wSEpBg8B3x_&DAhj&#dY^4}%ZAU2D{&$VW&8OCp_w`iXa zi_>)2F+vJhmEkfpR|>n~y`VZ;e~o5+r?kxi4e2Dth4%4?{o28;BUQ*6S;*2YuQT_W ze|a7pqW(&A23G@}ev;?25m$2#_u`shmg}BsE-!lB)ALo}u6u3)%j3ESoA49DXVK8~ z)6-oHO~aEC~F99_?%ERECT7M~N z%C;$q;uEpG*(9|hxBtTq-SKQVyuSpV00s=t%~aET zRY9xQ3s%@@g;n=4Ia1?yxe5dimVYl>Y9_~X3ZcqY2t|<$(9lUn&>Yl_=yHwE*oJn{ zvItH`Z|?5;^}8+Mhhe3KX=L8tW{ARNMgF^xJ8i$`#Ts((PLsoNN_ zos!8?nZ4cxku`aL<}+Ayub4{#ug;fX_wEeoJTOn(R^2*nD8*UZ8oC>9YeNRIJJ{sq zmrrp%kY(RTiT7amGI7@b>vgPi%cm86x$O(YvumDPZTYu(x?vIWFeO()Y=g4c5>X;U zAR1RSv&C=%6~{SydP>A}k$+sbTD6D2<0?2ZoT@co$4?dZCP3D_!q*}fSZIjcp|r|! zx?vMyC{mfM6lUL*>Jh9Ewo#xvsLp+cYfW&X#S$?}rxM)B$PS92gA(5OKZd8HskL0e zMaJaG5l_Xn$F0m(D~|s2KmOnE8Q93xE4x6hJNoigvH$s>|MCCHvwuCb&jCw8dGHFA z_GSp4aHzWT6~kQv&*^>6GSEf_`9S1z+iVmd*X10dkqcU(x&GnflAtN}vARy(^V}UR zs5kgn>qY5d^BrhN$zD1(x}{^Y%mIK0aZa*h{}aP1%77^ z8P(yV9&%@Lw{Y)jH-7`lst%w2UVp%URq_g>Eh4`|3$y*a)z!99u!B_U20jQTz?-0_GASnLaJK~S16~U z8Jgq|oXqmrBec~`E-q&c5n;yP5{Jt!q04`Yd;MaPq4XKb=UTm)-5NT8sGfCRhgP@7 zQ8t=PuNwIbV>+unykc6%BIk&V+hQF$kT3M~(ygc|GtUFf^|$DxU)ldRLfJPakp9)L zpaI7CiO#qAkNaccTWn0TA%D<@gsUJMobY% zYEX2fle0~;t$Cfh~aG~$oD*i+w5*GWX zXh+1Ph%ZdODzCZHZ9C~JHAuKhAZ}Y7kbK+F|2Y%L?>0?kn4#eM6<^6Jm5mv&5e>}7 z3b+7ah=03PBK?W;X|TGOb2~aHkW^%HVXA_oHOgY~NV4zXDxpzmgUA}s(oiV(q{Rr5 zTn&JKzzxk_Y24G3GKV=ydv?*q5379y)%DPDs(!a-%Y$_H=^sDBUH_LcQ_b`n*!uzVGdO`vc!zK;T^ zEgu8GsgZiltk|#j+G{9X;H|4gAP0fR9SBDE?#ZTZ(BVE-q7}`r15NzRT^n2Ndg?Ad zx#ES4xz(?G=T!_g@0`c76p2S<5*vwr`w=4vo?KxkGSNG5uma6Ug|9eG>V}KCd?emX zOMgVN^iZFCq^g)}EJ#jCqPx%n6aB>FF!U7HcZV0<4lpmfV-g>+@J-LIxJV3J%~3H% ziDEwRHJMQ6(^&21&0QF>VfGBXoq`oDI`(8v9`GmrBFMXN8E*qVl>KRdrSnmX zhNTVwj1q~gwMJY(aL|l6*OHaTPF;{HBf@`t2r&>gyf4@reWVgQD}IWNe_rZG}-2e9C%dnw42(*}bJfiD9{E-KY1^e}^AgMSMJSBiu&N!WxN z5^+lws4N3pF65Yz0%Tg$W>|X}2E-m#0ekd}A__m4I;1I6;p~aj+%ZB5dYMx|xzNU$iCV ziXp%y0{ja=vip@`8Go?+0|~Fpypj>ld0a=fbr>gtXgI#)>+U79Cx- zRR153`oCiM?=AZOgGZ|m=k@>fhhMJ#rvHB){r}~Q?O!1LcYka#pA5P~-)sv#3$^-e z`hPGl`?s7vJv?K{khzq}DXvtQ9)FZ2aH9Pjz<_3B9QT@9dUN-Spa(BIttbW(PB&Zm zRql~|*(?l;l&M;fs7Bc_4w+U~xYW2vF81zxrkq3MK#@j4l5AKug4K+RVm2HI6mS+< zsk9U=#Ec7G-+x8EqC@1uRN|Ws87q{Chk?813s%<{psnxqRY|y{V9Gc$=6gtjNDeNK z)ey|F@V$liST(N2@EwIr@f#2nJF-RBO0IMPox55K%^|8DIvK4v*BF z(@+Ds=6^8yAm6+x58Qb-SxVMfhY}dJj@b@Z;N)VGj(_;)7;Fp6(Q)7H(F7CYL2kM) zeO=IaB85ksHp`cf%{CMo6=wUC-pc@|BXxp{fr^y)gZiPSm!&Ew168L}5`jiFsW|PI z1)8M2hiA=6q+CB<$_!beW>p`ypDBZC_-buI$bUpTNI~WLDkNv-n$_4iRU4xal8Y=* zgg~n$^7Lgdgzb4e2PtQAGg~4x!lPDM-ARPr=T_V(C$-n+-#(v(U|k8o-gv@MAQXEnb@DGE+sl&)<_ zIe!S!JsSwBDf81f4usy2-Hb$LiS45;%o|&b}g4T=GMCFo%@|Neu@uoh0=`q z^h$o^{L3#yWk4?Q{5r*8J2ABRxM>+z^hQ$a3ENoBzmd?xc#w}(U}IFxHhH|TdxtxF zJ9G&uuxjwb%dHnXy@Rdo9T)vkUpDSH(0?lpHB zX%=Iz&RFfJFrFDCAaJov{iZxR;-AC*s1v@L1H7dOJ;vD#Vbc>@x_sCQE`i7g;dULD zZ?!g}r8_?cLdeGd?9IiON@boPq+c$W#k?6F&W9ywvPqDUwwr!&r%EoQpngnadw(VX zH8f~HePW9&H8nKH-{07l+L9dMZQl8D{;LhmxcSgpBw>1d@ym@^PekpFu#2XzH)1_7 zAAX^Xj_tiyJz9&C7h5m4p3%S0ceb9MJm2pf(EzUfZ6m&O*D8laakcVcp_yKf%b`eF z%Egw#pEu$z;556Ps=DZ!P5VQu#TzLFaY@^F)7wjpDNfNh-9dKQ9nXeC_dDa>)0047 z7Jt9IkFK&ROzrSuPYAQ$C=r?AKO9wmR9&FD`|`Wt=rP~S%f7Qb;`sM}kR+Iq{LpWo0|vvK~HS+=~=nyVVrJgJ5S{Dg_xlMNMxLUi1QNo?L(+zHNam-a6hNw?X$zJZ>g;l0OVt&o3-j7KZkWS6@Lri z?Pn%aD-|+Was3}`^?Kj$A3psx`!_x|KZd`B#6nMP6NwpPrM;V9>c#@dK~~uO()_Ib z{in&qyN(I|!=>dZE~gt#Ms~47wZot zhO8AUnvbjhB6zD)UG@#;RH}7s{C{Ef53ASqr>F1M&Mcwtrk=XSKyK*_=`9ke^eP{0 z1{~^^_qH06IId?R*QG(uWlGXdFZz>Per0mQ<&qB43GB@aB%=?)F8!QtqK1pcN%TvL z?c?(5RTp%X%CugB-(7R7{cT@HRrX z2bwO@d+u~yOiEmt&I{2HgcCRjf_ird+^d6QtL>>7z+d-`YZdQHSK^tm+o*nLIYHJ*Q~AEO4^CNX~!NM{&&uGYYGu84-&o3AS|U{OV8@GH(Z z;Ul({Gsu4{IMJCeF1}b=Tz`;lVfG1%BbzNI1oo&3U9rF~Bu)}3?OZF3=N@^w+MPJi=Gx~~T)rZ}v= zX?-mf&aS>WDvKE`>lZ~ag;ALiH^hF;6(CVWS2)AhU3;h1+Cr^%klNX7NJA>#(ql^Z z-c{$)MKrbr8%#ZP)aAhjdM-=t>t$}!BGDsl`$NlWn$_Qt&77k_Z0z<96CEe+@NsoT z;qjZ*y;5>C<2Mq(bcwCUUfrJ{3xRRp&1U@FMmeE?bKiA*ZQ>g0{{s* zqGK*CGv(>n#gWWUIzYy9m`96#BH>~~$#)FEmFFmt2+JIku8q2zn_(kS!5hVfm^#cs z!KLAZgpFq78iD3jHREo@*H?2zNgH7*b5LPx_pENl$BE|j-nKw!GNHM{rRe?$tl;}o z0k!F4K1Zv9g@5I-8u*BZ@dZsK4?fA_dyA$S)OX|>x3wCNM0*_0zz6m6K}dCaxh=}H zzi5YpVTXu~XjF*#3GR5S*k)XcuNV!bgFgjqBQdzC0b*HD;W2yPCq|ed4A=OBR^}CN zCA<|9YH!7tHTSkeP6bkL5fnTJ+sf4L*qKdyFk=B%*?(P|1~V&%8`0fID@2_Z6JkWn zW%c|#*#ngrtf?W-InZ5UXc+6Pyru|7)0!K{y6rj6^k^t}3|sKrm>}=cFgEY6Dxcw~ z60Un7e1ykK1zODf zX2IHo2Y+J~oaAG?gTs9|8p3`y7ntst0wDy~H(yp?cb#N_NS?;9C71W*FG%b**ePr_ ziNz3%jXF~WM688HAisF(3T6AMr|fNXqvbSgk}em6X@a1rxA_Fk9WTTky}A2^?Kk4* z`8$D@U<^f{QV`4yu_4R>Q(=j&;vQe{lcGZ2!GBfV3#(ZB6_+uGJx82DJ{bP7R*iHq zV~)rA0v57L5@!+pnbrUz4<`z5g_$#mHmfbSkf+Aj4Klusr4F}-4&e=rC6!^DM1TBJ z5zV6eorh5mh-T62J&P9ePVtM`Ij9Xq$Vd`5bU5iTei@>oaU1dLJ)ojJiMQ6)pTsX; ze}8-MV8yg4DOdFjVAfwn+Xt^mXn*t|dX_(tg!BD@54x}Apu(}5i z?h9i<^e!K#eufYTX0k{=5&LW62r-PepD!U>Y0Zy|0$9=^@`+9f$Rw&nZJ^5x-Xg5v zOGZ2aM|uy(310=S%Z?rr=x}g84J&Wz2p)^+2aEA%=tjq50#c_Lk$O@opu903a~`L& zaGo-VI$qc__~Fn6Hfs4(ljvq659j-1=GtjC>enB-)~NrBlNx6x ze}>C6AEa7!6pZo7I`KTo4|zYe`Uz%TbE1Zn@Pov`0-Y};Rc4kz$TzUNGeZ7VTIHe6 zJh$Y?tZ60*lGG9H*kfbYfjX5Tmgf?f7o!f_Vy`%fkv`sEbb7($#m$i9%h`M{K6) zK-G%w+MY<7eA?-kh?I5Ke^yX{oYz{5oY5?IuTPdSq7je=Ixp`zn-HR;TvvJND%r8f z&a>6v*+9aQqV;y;8b-PAoOAk|q%&^NIYQ(oQL#&p5XDApSQVL>iu}U3Dk3rmU<@&= zMWhjLq`3>K%}D0q5|Zc<;p1pVJ!v7H7d5Ko6a+T1dAWDjAWtO92TB( zidHQ${1POV=G|=El_-!dUjtoxue-ukje^+oTFr^i5_g{3p{za9b%w96Dh@#xya2mt zIFZBKzvZT+Gr3%uf0Rhms4nt}$wMxH{jpXcl5{gU3t`fkbBolH&iJ`yNg)p0Qi1iU z=^~MPC%mcBFlh#!GTYVgCb{K$69Lm<<~VQ@yOf|O!|KOCgX$MG4=tai#d=+lUHy_= z7n)laGaUHx5*xnNN%s-b#^UvD5^}bl?Yumy=iDcnf@8J=e{e%f5YrbQ*IrB@x4X0+ zpG4{HQlMSQSD^KiTFK7n60WmI(xDffrZr{`XKu|p@mLn(-;V^n<@{- zS+v|5W^&DJe@077k6SDjuqJtzrpYTj8^49IQNd?Byf7>L%KzAupC7m6S%+}AscoEP zbT;lLcYd^&i5p)Yuf6GLP}<$@zjyC++tl;QhsC-Z?dV4uJ>qeS!A7)nfN+&IN5Q6S z42pi-s$qCfWbUv@-H z%g?Uv{>U=HMpPU6`kOWlr~-eb{*QkoHkeVfFr6%EvE%WSzCcY0&86Uj)mi$0H`Dhg z9Ewfv-e3Q)g1(W?m{K=erzHRN_(Mmpzq@PeNHC|zKCH+RQ*R`2pWjV(gL|1yz=psv z-ucmXf1m>#uUYjWp%+Qyy!$WihvMyun-WnNbwZCU(5sI}Ym7A$edz$rfWNYy!Xuyo zy8{eTuD0B)^=U$=UKrvm_5YTNj@~lkL2D1!8bQf=pEe<>Gxz ze={)`d&es^hLG^sqJS192`RvO6k;nZ+T(nYHygCcVhWCukFByh1S!>8I?-W))OLn= z@n_Z{X0!i5=A)^Lxbz1r*m0s9D%Ez@)~x#4Yr_nzwwLLKPaA_LY|H8dbO z5TM9J!8`+?p#Xu=hd88$2(I@oQS9h3sBboA@T%zG^>+PnHHzu8o#?4I#fZ@ae;j5RC@QPjr zRozsvdi1CBu!gYCkflB$iY*AwP|b;tS?MkF4m+z3FQsO!yy_RM{-=Sl){DSFNMRqS zNS-08%g_}$6ILNQoW;|+AY{<5erTjky(y{BP$k( zyzT`FO#U6GC@}|8j9QIDf5|w13!yia1bLM-cjh8+NU(*my--R8yhDnt_*=v?jyj$d zSM@{zi+RsbEpAhe_p4nn&Ok6iJeIK_TJ8=HN{dCfC@>$T7TU^n=+3U|gT`pJI9ov` z%Dhudp`gNi?2xk_tgbmC^EX;F$}5hg6%B1pVEGL%|Gy;uzZteqfA}E&;uQdH#{XM; zgkoQF_X-*H5*SX{PPky#>=H5$W0T=LLL-y>KJM- z2hR#b^$iptz|Kaf&tr=v3-MEA7-bNCXD%d4=MfZ%e?%dGI@G(zv~8?h;OZCf zXn%s_s!>+;zn=_I34Oqi@)}+L=%Fi3$C%WlOmHN2otQ52>5wxw6K)dk-_+!6?2(UL zv!c6U*92{rq5MQkd6$;#u={QQQcb}Qm_!$)!*?(VV?8|P|CI$9 zoMv9sDr6;yf1uEZ<~og2ri>ox32)mzudh?#iwO=4IKLl^&O4 z1HtX-3ldDuNo<+OpF9B?19V=TF@`K#=n1RrxlqPPES;!6f5Q6{q`YEr*;TCYm5ZdIX^J%? zn{B)%z}ZpF0)M%yFfODu8#4r|42%as$8MBQ6%|s`1i2x21PukaAWh_L=1hscTof2( zz;~VmDBOpc#=pHQb5|UNS1Uv*4mevQ|Ef6R9Z^3gE^WlA-UihjK=6x+X-iQsxd@2D zDj$-{e?nBlm1C0gYzp{Kkd&c)zJn~AcuXoQr{zbU4!vPV_&eV&QeCU^=-DAdGz8ip z@EB%?G9MTMpnywOSL12=zB4!16HSiDs{mJ=C(0ZoEYAV#K-d->i)MU6V*%NFxO1F+ zzdXOxGCn#lt3MyHZJ@1eH4Au*V)kh5ellpJW5Q4(=eih6Fb*c=t zs+EXDc$wW04wM#Y-&SV<{wl-Oo-(W9FqNqXa1ZxisGSHv{4AgWsf#N$4L|`(4iW|+ z^dN~LFVcxCB-3+nn@>Zq@24r9%yEyYAPBjlPk-bpG@OlE7GwFunFTfyM6x6>m3FcA ze+Z@;`>H=My!m1Yu&iS;#&zo4A}k86Mx$T_I9!fa2FYWgb<3wBf@--s44v+I#C+pe zwe`mK3PaJm9aq=s72rSq|Msr_H;&}E{?5N*LL`wC?Ck2pvd%mo5K5w?^-&)dd9Z=Y zfjitCk|XVxXLdy~f%CVg->a(bn%yBOe8`G>k5{jpfCv*hM_JV7 zb{pna@R326bQ&vOs~lgr?lYp7+f(lx0Y3AF-cbRntQHGuNOaBVcy^-le*(%dr*J0a zM5BnEk4Y1Y;)y@cORG9zq`_9C-}Nv(@jdxVF`1l%Wh+OiGs%f4fBAJLFvM-mf6zI- zE*)`fwy+&Py^MNsn)$i1^1Jh$pyzh9(RE?_k@uR7l#|j$)#-$r_g)v?0; z`P&yw`&js<0<Wh>!5#{@9#RQ%l{v5(Y6=)MYpa*+MuvC(_AM zhP;t1BbEq~zJl}tsl$WspkNB=#oORWw44>KXAJ|)n(I&H6T45(C|;~rVqvfEvq0j$Oz4g~C4B#X=xqkj(mSorQJe_>|X)xoYuc5JZ-|9C!pf8-!+Y|vfRRQ^F0s=}K# zA7ikO^_E8D6l5Q68jNOc#%=ELv>a(>VsoQQ{FH@ng z=E#L;58Z3;qVHj`Ru`6yP10j~6h>fwG^0i@ZK>3`Po?QEtnhjtu+pP9J3EKMTFiSG zFmW1RMiaZFMI!MBRG}V;`KIn@)SjX%p&EPF9`*Ef75GNVO6?`TCinBs&eyQd5{9*? zh37WCdupF1ZxmiNy4+$ytZ@K1d?^^|#hKfaKz9^>PnvK~%E^wb%E7dX11gn4e-(T$ ztFtlAY+QbldW7gmPOd&*&q^`iGv?`7Ry1=hA$w+*&(D=pV!MZ+P~>RF(zP0Anezqf z9|U8YH?sq21fnU^7h%Xh}Sm_5YLSNF-4aE0e3v-OkmXo z>aS>fy`g-0qo&TqSVgd+LNmedTXnwF*}MsVuc)+P?#7@9yJ`86@ZNFPl4GCTQj6k1^kf{co{85Ss&0m4xe z!x`lcac@Ojbcg%mYReH$;jmj(XfV_}PQ1Drblp|KTIAwX@X(vb4g%mi-}ST9$dv$p zOsfAhNUF8p6ph&7SnXvk^xD~po-8j&@i3GXssVLH!CDt{f3ITKztX-3+Y!av z;jYEEwD3DTUW7hTMET&>HHX3KZhBGtNqydntJOL!4A@>Qcnuio1uI^EHLj!mpUCPM zD+N~z4Vt@vageuqTv3lgoBA+7^1Bl@Dky-Y@Z{C|cOZgaU;ZC-lg*&0@-xZqrd?~K zDm%A6YlEX7hlhuM8${ri{QuLZPad`N|BoL&efXQZ{Qp1VC(rYpIZ0Z+W%qXk!n+i_ z9}rzPAp%SdAZ2Gh{b{{_xP&7ijNkw^2A|E zNvU~DGqT{QTrvgIN{dA(8+tZsmqIaCggR@kIZ%#b+*7!6$~C#tY?DLwf*&u-&zd6ud(+T+xe{zYdi<C`&R0>0jX6)?Y}htF~668SnxKNZ1;h0;X$;e6x=(j zDuGqRdCr4va*5cJl0DiXiFNjF*}8$v%3a?Ytobr_B#Br7E`u?Qq%nW!T&{!^?JPBS zM912Sd}RC&F{zgFCPo~Cw<@PiNhVh7T}jRJ{0bU{wG$QyOnuTvLU5qo-<9yVGFIrc z5knuRrSA}b-GaMwh4L6$jN-g2g&QDp2m>oONNUg-#8jfrSG#dZz`d&wU&(0YIYg2B z6W08Ib;pL~w@CcCG| zb5;QCWm}MX`P?MC_g}lZ%RiP^yL-DfyWQTdxJKc3-z=Pey_e{)B49ld=8lwh5)oEK z%rn@38Xu2;g?nQNqSH}1EtX9=dE1OE6;UI$1ukX#HpI$p+3axQMd;=-e@ozFGrrY(xLO@`$jgRb%3)1MJi=>{~g@+Pfhmd zJ7(s}OIbK0kAC1J=NuHG>}+m45#cu%>r+yHM2NvL*dOM4f9L7`&Ut_L!>4z~lkA3% z>=>#r?Eilcw4K9gHBY~vWe=Y{eVVOTRqz8;1@Ji($|M##SQXD`Q(54SbD9!(PZ|jk`OzrhB{BZkExL z?1#q~X&8@Tzr^S7ySW~*e9TE{jEBnzE-|SD6%w>YV?aSsfES2yK`23%M~Luqxvw|m zB5fAHc8DaZQWNC|V$Aj}4NCZF5p+j?Sdobm^4P)ECof{ywx^Q#eMT}h;M|Lt4(>q*ZIo#ktwbgv+2&y;aexhP=CV&f7!8U-w)aD;9@Z= z2WXMhJ&oO+a{lGyOTikgpLf$s?e?n@RBbn;V{Ed#f~vQ;$yG2O2-h`0-Xc_aE` zj5rti;v;+fD0b~;(*ly@QOgzMQ#?YwV4%H%pPVVT2=zArOZd~am%c14A#1cs=P3|$3 z@|%>`$R6u@dPMkB%8K5qn0p<6Fe!&o0tn_ruKp7(JQl2yv|A3E=8x+t_7#mt(BTiR%~;>wjpcO@}4<=8#x)5)8dlj z@0W!*_E=!CnGTf2`n{XQAJ?Ve@67UT-&0MCJSyGU65}<0Na?##x&H-EwQe(F3=9l| z?phVF_sTW>a)49UEQ_#XP7*U8RrXTk_19ohv0t+sJ~A#?I%G_Pfu-yExYlj1rvuzuVC=)j ztbu-umDIZQ$UbXRI@I8zh*HL_^Gk|V`I~@Kilr6Q#5>cbi99fc<}M$)&B7C;3c%&i^KOv2QRbxFW>*^-2-|d zs@%;!AJOe!1_7FbG@D*X=fsV@Kg_cc$YCrp0(0kZl?|Kh9Wb)eW`n^7+T4j8-r@;@ zPPm9Fk{9Ezqe0<+EnSg)Bjm2;g*wWO{R(etPDW~9;@EOjKO@u|Mqex?oTYJJYV5io zrbP&PQje2J>F-wd<~LR@>FqcBwzv5kyRSXp;M)^gPGQ)LlMkwTuqyPJfqah{-HppC zRwhS2Jmu91Uyyib7lE#DUv@F=XxI2{79C}jr3dCCM(0wR!|UWX6l znp>Km%CXRYYhBmU0Jak#`~&oRBqW9`B;1KQew#%G6lUR2`?S#u$-cXMnuDzZM8<+4$$ z+`WmalEAV zS&qZ|4E(|N=~TK!*+`=Zi@IT|g1N3JIdv!c=)NU&j`2G|Iwpd!SI_!OQ4hlUh>JFV zo(-h;h4a|tKg-qF|1*AYP%M|K`tce0YrURh%z?t=NB%+Smpez5RgseLKGvDaDHTa2 z)@QhT`nnm_jtWK;eNrB)1${TYd!niw5 z%d$j~=xFq?8#kc+o?2f`7xWUpo#`jfq>jDXi7RHR_-AWEqH_rgfF<1Sn-NuiM?MXI z&++vK8l*A$Hm>nbFu#h1ghkMxoQ(Emli~(hErG4ZA`5G3nbkl|-ysVBxAtGS8&Ae4 z!}!0j{~kSk^z`wj{rBkE9skd-fd2;}z84S-YS`moVWI618WP!G&|&cv1A?maj%&SZ zp^srz9!aKPFI=@N4v?+vBS@fsfGG~wzvV*I+K6-~f?op`8NmbIG_5GD1>nYo75X^n z1E6rhI)G(|q^oC)5gcLL1nq-?8v$iZHYq;|cv?CV7v6uC^@20k2Ppqg&_|ak5Gn&` z{+Sl)1@Tr3aWt1GwEPPaCh+s6AaqY!jYNmLBG5tZKCWk}^p^XQM;1$eRUQ-#1zChm zd%I;_r;u&Wg65SzW?Fq__Akb5V=Oz|y(LrO$t;?qfmtC{couLytG-GK&8#7;KAW`% z`|}JTONJ<|?QpT#i&d(+F=KM#ZHU|Be)cCt{xe$r)2f;(W=2ql9rAWrXe%*hOPugj zwPW~$0dIn7#RarX8!MN85{>{ixG2e}%tWaZnqya&K(vRvqsa$t4$W1e_)7ahndC>g z*J;P=xHWA)p0#unn#w^O@HlNSu||D4j83#VyiThAB59vCvcT+wUJBq#SCF+^;euSm zYE)~!2Sx^s7D<8NB`&J-i>ZKMZQ9mhF1-3=A*Of}JqEUS3BJgGR7;4{3vO=zleGQ$ zAtTJrlo`g7&<-K!(jym=JPTsb36zw!cM*(YcA{_yvHXh8kY%`peM{_`mL}24eguec z9Mbs65~D38W|V7W6CZiamZ>UubEg~=7LvPdC(L9n_1|hYD!E1uV%*TZeL`;Q!sRUX zS=I>dX$fKMni^SugSqn)&zaIhDizT(+ATOtD!lcwiFImpeHz<|bb8uFnqxX{N@aLcT7J_<+U0by_?*99lS1NSDRD{+`e9l~O6EF^(S}1K1y!7}ed^o_ z?;K!YdwVw&18*}N<%Tt_UdXC>8A+z0D%vNX{=U#uO@v^7b%G1;%mWryCPPOnVp)msAaw68`>wog*DS6G3|{HJBdo+~w2!z*4{ z7lg-E0?a{wOGN3^gNyr7p(8kYp>|GpuO_ry-oY zZNW$4?B(k}i>C~AzpiU(DbU#X zN?~w$!OD`$0==Vxt+iM;Oa-`#5~uRT^9G02av6Mi-euvJmtqQp-%P8NJcjOs<)KJ{ zF3Xf-Nt<-*0nqbF(iSSpZEBXW%-|G_rFE&66`#-fD|SaFjE?~axu zei;Mwrd6T?!QK}vQ{}rD|Bc&cG4kNppvOgj^1)iW8!vaJ=+v*^jK$2gQ=qD{y}94@ zqs+b$(K!w#$oY+1T8KN9aq7Xe)abTAvc;yypkhyR2YYoU?c7T*rp zbr@Y&PA!>DG7%}xd{Uj!`@k(woYQi`ykomQ@kwts;_br@U)A;8p0=dqVPmvha^9?e zrxuS6)lV&@&uVW*_GKl-(r2Z$znIx!zNqS++=G}r%ZPXxkw|QJ zb&DEL2qx~f+MVffA6wB&Jnxwx8 zk6N6a(V<2@x7pm&M_})pU^@@c$i}%{!=Nn?HtH~=!*YXV2;OPS?e$)p1v<9RmL>ERX0WVy(Ea7a+oY}D^pSW_KDHq{PUf!JeqqLTF0@C} z>eb+H3uVq=$|qf+WfRn|@M$g5LUrJBlj83=8m%oSO)q7E>^SL@=$&E+lXDE4eCv=u z?q^4^$w(}cKPM8;Zt3peiwofv8>A)1+!s3$x0((|ZOyAP1zVV;c5mN*qI-<{{!;MN zQr=QTR`PHY2GP6>lDM9V(ba$+$^Z?Ga+{~t8=E96cIr}Q+)!bPS>`d1xpA*AyM+Z^ zI>Hv8E4hnHZ)t|oph$yA=+YCtwB7s7h~k8tRC*>0-B$|X){~}H&rxV6u{KK!;-J(1 zMpt8FTT5s&BVA3a(PngiR?_*l3_a-!!49c*UIocuL#dCnHDq_#)lg0x?WhT0QL^L9 zNlY(N=BkJO%-$XYhU30kmG$*e+qiw6h)0@|>n7reuV<%Cu8reQ^j|P_lMh>WJjz~Lp)k{N!q)gyPvzCyPvzC SyPsdr&;I}u`@Jdv-~j-WNP5Kp diff --git a/helm/mosaic-stack/files/llm-compat-server.mjs b/helm/mosaic-stack/files/llm-compat-server.mjs new file mode 100644 index 00000000..7624d1eb --- /dev/null +++ b/helm/mosaic-stack/files/llm-compat-server.mjs @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import http from 'node:http'; +import https from 'node:https'; +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'; + +const SEARCH = 'opensearch_search'; +const textDecoder = new TextDecoder('utf-8', { fatal: true }); + +function readBody(stream, limit = Infinity) { + return new Promise((resolveBody, reject) => { + const chunks = []; + let bytes = 0; + stream.on('data', chunk => { + bytes += chunk.length; + if (bytes > limit) { + reject(new Error('Research response exceeds the compatibility buffer limit.')); + stream.destroy(); + return; + } + chunks.push(chunk); + }); + stream.on('end', () => resolveBody(Buffer.concat(chunks))); + stream.on('error', reject); + stream.on('aborted', () => reject(new Error('Upstream response ended before completion.'))); + }); +} + +function searchSchema(tools) { + const tool = tools?.find?.(item => item.type === 'function' && item.function?.name === SEARCH); + const parameters = tool?.function?.parameters; + return parameters?.type === 'object' + && parameters.properties?.query?.type === 'string' + && Object.keys(parameters.properties).length === 1 + && parameters.required?.length === 1 && parameters.required[0] === 'query'; +} + +function prepare(method, path, raw, researchToolCalls) { + if (method !== 'POST' || !path.endsWith('/chat/completions')) return { body: raw }; + let json; + try { json = JSON.parse(raw.toString('utf8')); } catch { return { body: raw }; } + if (!json || typeof json !== 'object' || Array.isArray(json)) return { body: raw }; + json.reasoning_effort = 'none'; + const research = searchSchema(json.tools); + const named = json.tool_choice?.type === 'function' && json.tool_choice.function?.name === SEARCH; + const automatic = json.tool_choice == null || json.tool_choice === 'auto'; + const initial = json.messages?.at?.(-1)?.role === 'user' + && json.tools?.some?.(item => item.function?.name === 'task'); + const normalize = researchToolCalls && research && (json.n == null || json.n === 1) + && (named || (automatic && initial)); + const stream = json.stream === true; + if (normalize) { + json.tool_choice = { type: 'function', function: { name: SEARCH } }; + json.chat_template_kwargs = { ...json.chat_template_kwargs, enable_thinking: false }; + // Some affected providers discard named arguments in their nonstream path. + json.stream = true; + if (!stream) json.stream_options = { ...json.stream_options, include_usage: true }; + } + return { body: Buffer.from(JSON.stringify(json)), research, normalize, stream }; +} + +function decodeBody(raw, headers) { + const encoding = String(headers['content-encoding'] || 'identity').toLowerCase(); + if (encoding === 'gzip') raw = gunzipSync(raw); + else if (encoding === 'deflate') raw = inflateSync(raw); + else if (encoding === 'br') raw = brotliDecompressSync(raw); + else if (encoding !== 'identity') throw new Error('Unsupported upstream response encoding.'); + return textDecoder.decode(raw); +} + +function validArguments(text) { + let value; + try { value = JSON.parse(text); } catch { throw new Error('Named research tool arguments are not complete JSON.'); } + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).length !== 1 || typeof value.query !== 'string' || !value.query.trim()) { + throw new Error('Named research tool arguments do not match the query-only schema.'); + } +} + +function normalizeResponse(raw, headers, stream) { + if (!String(headers['content-type']).includes('text/event-stream')) { + throw new Error('Expected a streaming named research response.'); + } + const events = []; + let done = false; + for (const frame of decodeBody(raw, headers).replace(/\r\n/g, '\n').split('\n\n')) { + const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n'); + if (!data) continue; + if (data === '[DONE]') { done = true; continue; } + const event = JSON.parse(data); + if (event.error) throw new Error('Upstream reported an error in the research stream.'); + if (event.choices?.length > 1) throw new Error('Expected one research completion choice.'); + events.push(event); + } + const metadata = events.find(event => event.id && event.model); + if (!done || !metadata) throw new Error('Research stream did not complete.'); + let content = '', reasoning = '', finish; + const calls = new Map(); + for (const event of events) { + const choice = event.choices?.[0]; + if (!choice) continue; + if (choice.index !== 0) throw new Error('Unexpected research completion choice.'); + if (choice.finish_reason) finish = choice.finish_reason; + const delta = choice.delta || {}; + content += delta.content || ''; + reasoning += delta.reasoning || delta.reasoning_content || ''; + for (const part of delta.tool_calls || []) { + const value = calls.get(part.index) || { id: '', type: 'function', function: { name: '', arguments: '' } }; + if (part.id) value.id = part.id; + if (part.function?.name) value.function.name += part.function.name; + if (part.function?.arguments) value.function.arguments += part.function.arguments; + calls.set(part.index, value); + } + } + if (!['stop', 'tool_calls'].includes(finish)) throw new Error('Research tool generation did not finish normally.'); + let call; + const performed = calls.size === 0; + if (calls.size) { + if (calls.size !== 1 || calls.get(0)?.function.name !== SEARCH || !calls.get(0).id) { + throw new Error('Upstream returned an unexpected research tool call.'); + } + call = calls.get(0); + validArguments(call.function.arguments); + if (stream) return { body: raw, headers, performed: false, toolCallId: call.id }; + } else { + validArguments(content); + call = { id: `call_research_${randomUUID().replaceAll('-', '')}`, type: 'function', function: { name: SEARCH, arguments: content } }; + content = ''; + } + const usage = events.findLast(event => event.usage)?.usage; + const meta = { id: metadata.id, created: metadata.created, model: metadata.model }; + if (!stream) { + const message = { role: 'assistant', content: content || null, tool_calls: [call] }; + if (reasoning) message.reasoning = reasoning; + const value = { ...meta, object: 'chat.completion', choices: [{ index: 0, message, finish_reason: 'tool_calls' }], usage: usage || null }; + return { body: Buffer.from(JSON.stringify(value)), headers: { 'content-type': 'application/json' }, performed, toolCallId: call.id }; + } + const first = { role: 'assistant', content: '' }; + if (reasoning) first.reasoning = reasoning; + const chunks = [first, { tool_calls: [{ index: 0, ...call }] }, {}].map((delta, index) => ({ + ...meta, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: index === 2 ? 'tool_calls' : null }], + })); + if (usage) chunks.push({ ...meta, object: 'chat.completion.chunk', choices: [], usage }); + const body = Buffer.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n'); + return { body, headers: { 'content-type': 'text/event-stream' }, performed, toolCallId: call.id }; +} + +function sendBody(res, status, headers, body) { + const output = { ...headers, 'content-length': String(body.length) }; + delete output['transfer-encoding']; + res.writeHead(status, output).end(body); +} + +export function createCompatibilityServer({ upstreamBaseUrl, researchToolCalls = false, auditDirectory = '', timeoutMs = 180_000, maxBufferedBytes = 16 * 1024 * 1024, log = () => {} }) { + const upstream = new URL(upstreamBaseUrl); + if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password) throw new Error('Invalid upstream URL.'); + async function audit(id, record) { + if (!auditDirectory) return; + await mkdir(auditDirectory, { recursive: true, mode: 0o700 }); + await writeFile(join(auditDirectory, `${id}.json`), JSON.stringify(record, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + } + return http.createServer(async (req, res) => { + if (req.url === '/health') return res.writeHead(200).end('ok'); + const id = randomUUID(); + let record, audited = false; + try { + const target = new URL(req.url, upstream); + if (target.origin !== upstream.origin) throw new Error('Request must use the configured upstream.'); + const original = await readBody(req); + const prepared = prepare(req.method, target.pathname, original, researchToolCalls); + const shouldAudit = researchToolCalls && prepared.research && Boolean(auditDirectory); + if (shouldAudit) record = { id, at: new Date().toISOString(), originalRequestBase64: original.toString('base64'), forwardedRequestBase64: prepared.body.toString('base64'), headersExcluded: 'Authentication and cookie headers are never recorded.' }; + const headers = { ...req.headers, host: target.host, 'content-length': String(prepared.body.length) }; + delete headers['transfer-encoding']; + if (prepared.normalize) headers['accept-encoding'] = 'identity'; + const client = target.protocol === 'https:' ? https : http; + await new Promise((resolveRequest, reject) => { + const out = client.request(target, { method: req.method, headers }, async response => { + try { + if (!prepared.normalize && !shouldAudit) { + res.writeHead(response.statusCode || 502, response.headers); + response.on('error', reject); + response.on('end', resolveRequest); + response.pipe(res); + return; + } + const raw = await readBody(response, maxBufferedBytes); + if (record) Object.assign(record, { upstreamStatus: response.statusCode, upstreamResponseBase64: raw.toString('base64'), upstreamContentType: response.headers['content-type'], upstreamContentEncoding: response.headers['content-encoding'], upstreamResponseComplete: true }); + let output = { body: raw, headers: response.headers, performed: false }; + if (prepared.normalize && response.statusCode === 200) output = normalizeResponse(raw, response.headers, prepared.stream); + if (record) { + Object.assign(record, { normalizationPerformed: output.performed, toolCallId: output.toolCallId, returnedResponseBase64: output.body.toString('base64') }); + await audit(id, record); audited = true; + } + if (output.performed) log({ event: 'research-tool-normalized', requestId: id, toolCallId: output.toolCallId, upstreamBytes: raw.length, returnedBytes: output.body.length }); + sendBody(res, response.statusCode || 502, output.headers, output.body); + resolveRequest(); + } catch (error) { reject(error); } + }); + if (prepared.normalize || shouldAudit) out.setTimeout(timeoutMs, () => out.destroy(new Error('Research upstream request timed out.'))); + out.on('error', reject); + out.end(prepared.body); + }); + } catch (error) { + if (record && !audited) { + try { await audit(id, { ...record, error: String(error.message), returnedSuccess: false }); } + catch { /* The failed response below does not claim successful auditing. */ } + } + if (res.headersSent) return res.destroy(); + sendBody(res, 502, { 'content-type': 'application/json' }, Buffer.from(JSON.stringify({ error: { message: String(error.message), type: 'research_tool_compatibility_error', request_id: id } }))); + } + }); +} + +if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) { + createCompatibilityServer({ + upstreamBaseUrl: process.env.UPSTREAM_BASE_URL, + researchToolCalls: process.env.RESEARCH_TOOL_CALLS_ENABLED === 'true', + auditDirectory: process.env.RESEARCH_TOOL_CALLS_AUDIT_DIR || '', + log: value => console.log(JSON.stringify(value)), + }).listen(Number(process.env.PORT || 8000), '0.0.0.0'); +} diff --git a/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts b/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts index cc800b35..14fb47de 100644 --- a/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts +++ b/helm/mosaic-stack/files/openclaw-seed/extensions/iraop/index.ts @@ -297,7 +297,7 @@ function toOpenClawResult(mcpResult: unknown) { .join("\n"); throw new Error(typeof text === "string" && text ? text : "iraop tool call reported isError"); } - return { content: r.content, details: mcpResult }; + return { content: [...r.content], details: mcpResult }; } const text = typeof mcpResult === "string" ? mcpResult : JSON.stringify(mcpResult, null, 2); return { content: [{ type: "text", text }], details: mcpResult }; @@ -316,6 +316,37 @@ function firstTextContent(result: { content?: unknown[] }) { .join("\n"); } +function queryEvidenceProblem(mcpResult: unknown): string | null { + const candidates: unknown[] = []; + if (mcpResult && typeof mcpResult === "object") { + const raw = mcpResult as Record; + candidates.push(raw.structuredContent, raw); + if (Array.isArray(raw.content)) { + for (const item of raw.content) { + if (!item || typeof item !== "object" || !("text" in item) || typeof item.text !== "string") continue; + try { candidates.push(JSON.parse(item.text)); } catch { /* Try the next content block. */ } + } + } + } else if (typeof mcpResult === "string") { + try { candidates.push(JSON.parse(mcpResult)); } catch { /* Report an unreadable research response below. */ } + } + const response = candidates.find((candidate) => + candidate && typeof candidate === "object" && !Array.isArray(candidate) + && "answer" in candidate, + ) as Record | undefined; + if (!response || typeof response.answer !== "string" || !response.answer.trim()) { + return "IRAOP did not return a readable, nonempty research answer with document sources."; + } + const hasSource = Array.isArray(response.sources) && response.sources.some((source) => + source && typeof source === "object" + && typeof source.source_file === "string" && source.source_file.trim() + && Number.isInteger(source.page_number) && source.page_number > 0, + ); + return hasSource ? null : "IRAOP returned no usable document sources. The research is incomplete. " + + "Do not present its answer as retrieved documentation or use it to justify a repair. " + + "Missing sources do not establish that a collection is empty."; +} + function summarizeQueryResult(result: ReturnType, elapsedMs: number) { const text = firstTextContent(result).trim(); const elapsed = Math.max(1, Math.round(elapsedMs / 1000)); @@ -398,6 +429,21 @@ export default definePluginEntry({ postSubagentEvent(subagent, "delta", message); }); const openClawResult = toOpenClawResult(result); + const evidenceProblem = queryEvidenceProblem(result); + if (evidenceProblem) { + postSubagentEvent(subagent, "error", `IRA research incomplete: ${evidenceProblem}`); + return { + // The tool transport completed. Research completion is explicit in + // content; returning normally also preserves the raw MCP details. + // OpenClaw assigns its own isError flag to the resulting message. + content: [{ type: "text", text: JSON.stringify({ + status: "incomplete", + error: evidenceProblem, + evidenceScope: "No document-grounded answer is available from this call. The full original response is retained in tool details for inspection.", + }) }], + details: result, + }; + } postSubagentEvent(subagent, "complete", summarizeQueryResult(openClawResult, Date.now() - startedAt)); openClawResult.content.unshift({ type: "text", text: "Evidence scope: documentation research. This service has not inspected the current workload. Proposed local causes remain hypotheses until checked against live evidence on the participating resources." }); return openClawResult; diff --git a/helm/mosaic-stack/templates/llm-compat.yaml b/helm/mosaic-stack/templates/llm-compat.yaml index e763a0d9..c265d7e6 100644 --- a/helm/mosaic-stack/templates/llm-compat.yaml +++ b/helm/mosaic-stack/templates/llm-compat.yaml @@ -2,6 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 {{- $c := .Values.llm.requestCompatibility -}} +{{- $r := $c.researchToolCalls | default dict -}} +{{- $research := and .Values.modules.research.enabled ($r.enabled | default false) -}} +{{- $audit := and $research ($r.auditEnabled | default false) -}} {{- if and .Values.modules.execution.enabled $c.enabled }} apiVersion: v1 kind: ConfigMap @@ -11,48 +14,8 @@ metadata: app: {{ $c.name }} {{- include "mosaic-stack.labels" . | nindent 4 }} data: - server.js: | - const http = require('http'); - const https = require('https'); - const upstream = new URL(process.env.UPSTREAM_BASE_URL); - const port = Number(process.env.PORT || {{ $c.port }}); - - function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on('data', chunk => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks))); - req.on('error', reject); - }); - } - - function patchBody(req, body) { - if (req.method !== 'POST' || !req.url.endsWith('/chat/completions')) return body; - try { - const json = JSON.parse(body.toString('utf8')); - json.reasoning_effort = 'none'; - return Buffer.from(JSON.stringify(json)); - } catch { - return body; - } - } - - http.createServer(async (req, res) => { - if (req.url === '/health') return res.writeHead(200).end('ok'); - const body = patchBody(req, await readBody(req)); - const target = new URL(req.url, upstream); - const headers = { ...req.headers, host: target.host, 'content-length': String(body.length) }; - const client = target.protocol === 'https:' ? https : http; - const out = client.request(target, { method: req.method, headers }, upstreamRes => { - res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers); - upstreamRes.pipe(res); - }); - out.on('error', error => { - res.writeHead(502, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: String(error.message || error) })); - }); - out.end(body); - }).listen(port, '0.0.0.0'); + server.mjs: | + {{- .Files.Get "files/llm-compat-server.mjs" | nindent 4 }} --- apiVersion: apps/v1 kind: Deployment @@ -71,8 +34,12 @@ spec: labels: app: {{ $c.name }} annotations: - checksum/config: {{ printf "%s-%s-%s" .Chart.Version $c.name (include "mosaic-stack.llmUpstreamBaseUrl" .) | sha256sum }} + checksum/config: {{ printf "%s-%s-%s-%s-%v-%v" .Chart.Version $c.name (include "mosaic-stack.llmUpstreamBaseUrl" .) (.Files.Get "files/llm-compat-server.mjs" | sha256sum) $research $audit | sha256sum }} spec: + {{- if $audit }} + securityContext: + fsGroup: 1000 + {{- end }} {{- include "mosaic-stack.imagePullSecrets" . | nindent 6 }} {{- include "mosaic-stack.nodeSelector" . | nindent 6 }} {{- include "mosaic-stack.tolerations" . | nindent 6 }} @@ -80,12 +47,18 @@ spec: - name: proxy image: {{ include "mosaic-stack.image" .Values.mosaicUi.image | quote }} imagePullPolicy: {{ .Values.mosaicUi.image.pullPolicy }} - command: ["node", "/config/server.js"] + command: ["node", "/config/server.mjs"] env: - name: PORT value: {{ $c.port | quote }} - name: UPSTREAM_BASE_URL value: {{ include "mosaic-stack.llmUpstreamBaseUrl" . | quote }} + - name: RESEARCH_TOOL_CALLS_ENABLED + value: {{ $research | quote }} + {{- if $audit }} + - name: RESEARCH_TOOL_CALLS_AUDIT_DIR + value: /audit/records + {{- end }} {{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }} - name: NODE_EXTRA_CA_CERTS value: /etc/llm-ca/ca.crt @@ -102,6 +75,10 @@ spec: - name: config mountPath: /config readOnly: true + {{- if $audit }} + - name: research-audit + mountPath: /audit + {{- end }} {{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }} - name: llm-ca mountPath: /etc/llm-ca @@ -111,6 +88,11 @@ spec: - name: config configMap: name: {{ $c.name }} + {{- if $audit }} + - name: research-audit + emptyDir: + sizeLimit: 128Mi + {{- end }} {{- if and (eq .Values.llm.mode "external") .Values.llm.external.caCertificateSecret }} - name: llm-ca secret: diff --git a/helm/mosaic-stack/tests/chart-render.test.mjs b/helm/mosaic-stack/tests/chart-render.test.mjs index 6052459c..83bc79d4 100644 --- a/helm/mosaic-stack/tests/chart-render.test.mjs +++ b/helm/mosaic-stack/tests/chart-render.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, readdirSync, existsSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdtempSync, readFileSync, readdirSync, existsSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -437,7 +437,44 @@ test("uses one no-reasoning request contract for external endpoints", () => { "--show-only", "templates/llm-compat.yaml", ); assert.match(output, /json\.reasoning_effort = 'none'/); - assert.doesNotMatch(output, /DISABLE_THINKING|VLLM_UPSTREAM|chat_template_kwargs|scrubVisibleThinking|pipeOpenAiStream/); + assert.doesNotMatch(output, /DISABLE_THINKING|VLLM_UPSTREAM|scrubVisibleThinking|pipeOpenAiStream/); + assert.match(output, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "false"/); +}); + +test("research compatibility is opt-in and requires the research module", () => { + const args = ["--show-only", "templates/llm-compat.yaml"]; + const disabled = render(...args, "--set", "llm.requestCompatibility.researchToolCalls.enabled=true"); + assert.match(disabled, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "false"/); + assert.doesNotMatch(disabled, /name: research-audit/); + const enabled = render(...args, "--set", "modules.research.enabled=true,llm.requestCompatibility.researchToolCalls.enabled=true,llm.requestCompatibility.researchToolCalls.auditEnabled=true"); + assert.match(enabled, /name: RESEARCH_TOOL_CALLS_ENABLED\s+value: "true"/); + assert.match(enabled, /name: RESEARCH_TOOL_CALLS_AUDIT_DIR\s+value: \/audit\/records/); + assert.match(enabled, /fsGroup: 1000/); + assert.match(enabled, /sizeLimit: 128Mi/); +}); + +test("the rendered compatibility server is the tested source file", () => { + const output = render("--show-only", "templates/llm-compat.yaml"); + const source = readFileSync(join(chart, "files/llm-compat-server.mjs"), "utf8"); + assert.equal(configMapBlock(output, "server.mjs").trimEnd(), source.trimEnd()); +}); + +test("a compatibility source change rolls the proxy without a chart version change", () => { + const directory = mkdtempSync(join(tmpdir(), "mosaic-compat-checksum-")); + try { + const copy = join(directory, "chart"); + cpSync(chart, copy, { recursive: true }); + const checksum = () => { + const output = execFileSync("helm", ["template", "mosaic", copy, "--show-only", "templates/llm-compat.yaml"], { encoding: "utf8" }); + return output.match(/checksum\/config: ([a-f0-9]{64})/)[1]; + }; + const before = checksum(); + const path = join(copy, "files/llm-compat-server.mjs"); + writeFileSync(path, readFileSync(path, "utf8") + "\n// Independent test change.\n"); + assert.notEqual(checksum(), before); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); test("configures chart-managed vLLM for non-thinking generation", () => { diff --git a/helm/mosaic-stack/tests/iraop-response.test.mjs b/helm/mosaic-stack/tests/iraop-response.test.mjs new file mode 100644 index 00000000..44b6d8fe --- /dev/null +++ b/helm/mosaic-stack/tests/iraop-response.test.mjs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { stripTypeScriptTypes } from "node:module"; +import test from "node:test"; + +const source = readFileSync(new URL("../files/openclaw-seed/extensions/iraop/index.ts", import.meta.url), "utf8") + .replace('import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";', "const definePluginEntry = (entry) => entry;"); +const javascript = stripTypeScriptTypes(source, { mode: "strip", sourceUrl: "iraop-response-test.ts" }); +const { default: plugin } = await import(`data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`); + +const queryPayload = (answer, sources = []) => ({ + answer, depth: "quick", collections_searched: ["validation"], sources, +}); +const mcp = (payload) => ({ content: [{ type: "text", text: JSON.stringify(payload) }], isError: false }); +const sourceReference = { source_file: "control.pdf", page_number: 2, collection: "validation", doc_id: "control.pdf", url: "/api/v1/documents/control.pdf" }; + +async function fixture(result, run) { + const sessions = new Map(); + const events = []; + let nextSession = 0; + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (req.method === "GET" && url.pathname === "/mcp/sse") { + const id = String(++nextSession); + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }); + sessions.set(id, res); + res.write(`event: endpoint\ndata: /mcp/messages?session=${id}\n\n`); + req.on("close", () => sessions.delete(id)); + return; + } + let text = ""; + for await (const chunk of req) text += chunk; + const body = JSON.parse(text || "{}"); + if (url.pathname === "/events") { + events.push(body); + res.writeHead(200, { "Content-Type": "application/json" }).end('{"success":true}'); + return; + } + if (url.pathname === "/mcp/messages") { + const stream = sessions.get(url.searchParams.get("session")); + if (body.id !== undefined) { + const response = body.method === "initialize" + ? { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "iraop-test", version: "1" } } + : result; + stream.write(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result: response })}\n\n`); + } + res.writeHead(202).end(); + return; + } + res.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const tools = new Map(); + plugin.register({ + pluginConfig: { baseUrl: `${base}/mcp/sse`, timeoutMs: 3000, subagentEventsUrl: `${base}/events` }, + registerTool: (tool) => tools.set(tool.name, tool), + }); + try { + await run(tools, events); + for (let i = 0; i < 100 && !events.some((event) => ["complete", "error"].includes(event.phase)); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } finally { + for (const stream of sessions.values()) stream.end(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + return events; +} + +async function query(result) { + let value; + const events = await fixture(result, async (tools) => { + value = await tools.get("iraop_query").execute("validation-call", { question: "Read the indexed control document.", collections: ["validation"] }); + }); + return { value, events }; +} + +test("planning text without sources is incomplete and emits an error event", async () => { + const original = mcp(queryPayload("I will search for the document.")); + const { value, events } = await query(original); + assert.equal(JSON.parse(value.content[0].text).status, "incomplete"); + assert.deepEqual(value.details, original); + assert.ok(events.some((event) => event.phase === "error" && event.content.includes("incomplete"))); + assert.ok(!events.some((event) => event.phase === "complete")); +}); + +test("fabricated source-free answer stays complete in details and is withheld from model content", async () => { + const answer = "Verified code: MVE-2026-1000002. " + "未经检索的原始输出。".repeat(600); + const original = mcp(queryPayload(answer)); + const { value } = await query(original); + assert.equal(JSON.parse(value.content[0].text).status, "incomplete"); + assert.ok(!value.content.map((item) => item.text).join("\n").includes("MVE-2026-1000002")); + assert.equal(JSON.parse(value.details.content[0].text).answer, answer); + assert.deepEqual(value.details, original); +}); + +for (const [label, sources] of [ + ["missing", undefined], ["null", null], ["wrong type", "control.pdf"], + ["empty reference", [{}]], ["invalid page", [{ ...sourceReference, page_number: 0 }]], +]) { + test(`research with ${label} sources is incomplete`, async () => { + const payload = queryPayload("A factual claim.", sources); + if (label === "missing") delete payload.sources; + const { value } = await query(mcp(payload)); + assert.equal(JSON.parse(value.content[0].text).status, "incomplete"); + assert.match(value.content[0].text, /incomplete/); + }); +} + +test("an empty answer is incomplete even when a source is present", async () => { + const { value } = await query(mcp(queryPayload(" ", [sourceReference]))); + assert.equal(JSON.parse(value.content[0].text).status, "incomplete"); +}); + +test("valid sourced answer remains complete and evidence notes do not mutate raw details", async () => { + const original = mcp(queryPayload("A documented statement. " + "完整原文。".repeat(700), [sourceReference])); + const { value, events } = await query(original); + assert.ok(!value.isError); + assert.deepEqual(value.details, original); + assert.equal(value.content.length, original.content.length + 1); + assert.notEqual(value.content, value.details.content); + assert.equal(value.content[1].text, original.content[0].text); + assert.ok(events.some((event) => event.phase === "complete")); + assert.ok(!events.some((event) => event.phase === "error")); +}); + +test("structured MCP query payload can carry the source references", async () => { + const payload = queryPayload("Documented answer.", [sourceReference]); + const original = { content: [{ type: "text", text: payload.answer }], structuredContent: payload }; + const { value } = await query(original); + assert.ok(!value.isError); + assert.deepEqual(value.details, original); +}); + +test("unreadable research payload is incomplete with the raw response retained", async () => { + const original = { content: [{ type: "text", text: "Unstructured unsupported response." }] }; + const { value } = await query(original); + assert.equal(JSON.parse(value.content[0].text).status, "incomplete"); + assert.deepEqual(value.details, original); +}); + +test("MCP error remains a rejected tool call and error event", async () => { + const original = { content: [{ type: "text", text: "Backend query failed." }], isError: true }; + const events = await fixture(original, async (tools) => { + await assert.rejects(tools.get("iraop_query").execute("error-call", { question: "Read the document." }), /Backend query failed/); + }); + assert.ok(events.some((event) => event.phase === "error")); + assert.ok(!events.some((event) => event.phase === "complete")); +}); + +test("collection listing does not require answer or sources fields", async () => { + const original = { content: [{ type: "text", text: JSON.stringify([{ name: "validation", doc_count: 4 }]) }] }; + await fixture(original, async (tools) => { + const value = await tools.get("iraop_list_collections").execute(); + assert.deepEqual(value.details, original); + assert.deepEqual(value.content, original.content); + assert.ok(!value.isError); + }); +}); diff --git a/helm/mosaic-stack/tests/llm-compat.test.mjs b/helm/mosaic-stack/tests/llm-compat.test.mjs new file mode 100644 index 00000000..e6c80611 --- /dev/null +++ b/helm/mosaic-stack/tests/llm-compat.test.mjs @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtemp, readFile, readdir, rm, symlink } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gzipSync, deflateSync, brotliCompressSync } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import { createCompatibilityServer } from '../files/llm-compat-server.mjs'; + +const search = { type: 'function', function: { name: 'opensearch_search', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }; +const task = { type: 'function', function: { name: 'task', parameters: { type: 'object' } } }; +const input = () => ({ model: 'test-model', messages: [{ role: 'user', content: 'Search the indexed PDF.' }], tools: [search, task], stream: true, temperature: 1, max_completion_tokens: 4096 }); +function frames(content, { calls, finish = 'stop', done = true, usage, choices } = {}) { + const meta = { id: 'chatcmpl-real-upstream', model: 'test-model', created: 1793400000, object: 'chat.completion.chunk' }; + const delta = calls ? { tool_calls: calls } : { content }; + const result = [ + { ...meta, choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }] }, + { ...meta, choices: choices || [{ index: 0, delta, finish_reason: null }] }, + { ...meta, choices: [{ index: 0, delta: {}, finish_reason: finish }] }, + ]; + if (usage) result.push({ ...meta, choices: [], usage }); + return Buffer.from(result.map(value => `data: ${JSON.stringify(value)}\n\n`).join('') + (done ? 'data: [DONE]\n\n' : '')); +} +function parseFrames(body) { + return body.toString().split('\n').filter(line => line.startsWith('data: ') && line !== 'data: [DONE]').map(line => JSON.parse(line.slice(6))); +} +function returnedCall(body) { + return parseFrames(body).flatMap(event => event.choices || []).flatMap(choice => choice.delta?.tool_calls || [])[0]; +} +async function request(base, body, path = '/v1/chat/completions') { + return new Promise((resolveRequest, reject) => { + const req = http.request(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer never-record-this-auth-value' } }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolveRequest({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); + res.on('error', reject); + }); + req.on('error', reject); + req.end(body === undefined ? undefined : JSON.stringify(body)); + }); +} +async function fixture(options, run) { + const upstreamRequests = [], events = []; + const auditDirectory = await mkdtemp(join(tmpdir(), 'mosaic-research-compat-')); + const upstream = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + upstreamRequests.push({ path: req.url, headers: req.headers, body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : null }); + if (options.handler) return options.handler(req, res); + const response = options.response || frames('{"query":"MOSAIC-KB page 2"}'); + res.writeHead(options.status || 200, { 'Content-Type': 'text/event-stream', ...options.headers }); + if (options.fragment) { + for (const byte of response) res.write(Buffer.from([byte])); + res.end(); + } else res.end(response); + }); + await new Promise(resolveListen => upstream.listen(0, '127.0.0.1', resolveListen)); + const proxy = createCompatibilityServer({ upstreamBaseUrl: `http://127.0.0.1:${upstream.address().port}/v1`, researchToolCalls: options.enabled !== false, auditDirectory: options.audit ? auditDirectory : '', log: value => events.push(value), ...options.server }); + await new Promise(resolveListen => proxy.listen(0, '127.0.0.1', resolveListen)); + try { + await run(`http://127.0.0.1:${proxy.address().port}`, upstreamRequests, auditDirectory, events); + } finally { + upstream.closeAllConnections(); proxy.closeAllConnections(); + await Promise.all([new Promise(resolveClose => upstream.close(resolveClose)), new Promise(resolveClose => proxy.close(resolveClose))]); + await rm(auditDirectory, { recursive: true }); + } +} + +test('disabled research compatibility preserves the existing no-reasoning contract and raw reply', async () => { + const raw = frames('{"query":"plain unexecuted text"}'); + await fixture({ enabled: false, response: raw, audit: true }, async (base, requests, directory) => { + const body = input(); + const result = await request(base, body); + assert.deepEqual(result.body, raw); + assert.deepEqual(requests[0].body, { ...body, reasoning_effort: 'none' }); + assert.deepEqual(await readdir(directory), []); + }); +}); + +test('normalizes only complete named query arguments and retains the full original audit', async () => { + const argumentsText = JSON.stringify({ query: '完整检索内容。'.repeat(1000) }, null, 2); + const raw = frames(argumentsText); + await fixture({ response: raw, audit: true, fragment: true }, async (base, requests, directory, events) => { + const body = input();body.chat_template_kwargs = { custom_setting: 'retained' }; + const result = await request(base, body); + assert.equal(result.status, 200); + const call = returnedCall(result.body); + assert.equal(call.function.name, 'opensearch_search'); + assert.equal(call.function.arguments, argumentsText); + assert.deepEqual(requests[0].body.chat_template_kwargs, { custom_setting: 'retained', enable_thinking: false }); + assert.deepEqual(requests[0].body.tool_choice, { type: 'function', function: { name: 'opensearch_search' } }); + assert.deepEqual(requests[0].body.messages, body.messages); + const files = await readdir(directory);assert.equal(files.length, 1); + const audit = JSON.parse(await readFile(join(directory, files[0]), 'utf8')); + assert.deepEqual(Buffer.from(audit.upstreamResponseBase64, 'base64'), raw); + assert.deepEqual(Buffer.from(audit.returnedResponseBase64, 'base64'), result.body); + assert.deepEqual(JSON.parse(Buffer.from(audit.originalRequestBase64, 'base64')), body); + assert.equal(audit.normalizationPerformed, true); + assert.equal(audit.upstreamResponseComplete, true); + assert.ok(!JSON.stringify(audit).includes('never-record-this-auth-value')); + assert.equal(events[0].toolCallId, call.id); + }); +}); + +test('already structured named calls pass through byte for byte', async () => { + const raw = frames('', { calls: [{ index: 0, id: 'call_actual_provider', type: 'function', function: { name: 'opensearch_search', arguments: '{"query":"actual query"}' } }], finish: 'tool_calls' }); + await fixture({ response: raw }, async (base, _requests, _directory, events) => { + const result = await request(base, input());assert.deepEqual(result.body, raw);assert.deepEqual(events, []); + }); +}); + +for (const [name, modify] of [ + ['explicit none', body => { body.tool_choice = 'none'; }], + ['another named tool', body => { body.tool_choice = { type: 'function', function: { name: 'task' } }; }], + ['post-search synthesis', body => { body.messages.push({ role: 'tool', tool_call_id: 'actual', content: 'Full retrieved document content.' }); }], + ['subagent without task', body => { body.tools = [search]; }], + ['unrelated OpenClaw tools', body => { body.tools = [task]; }], + ['multiple requested choices', body => { body.n = 2; }], + ['a different search schema', body => { body.tools = [{ ...search, function: { ...search.function, parameters: { ...search.function.parameters, required: ['query', 'collection'] } } }, task]; }], +]) { + test(`${name} is forwarded without a forced search or response rewrite`, async () => { + const raw = frames('A complete ordinary response.'); + await fixture({ response: raw }, async (base, requests) => { + const body = input();modify(body); + const result = await request(base, body);assert.deepEqual(result.body, raw); + assert.deepEqual(requests[0].body, { ...body, reasoning_effort: 'none' }); + }); + }); +} + +test('nonstream callers receive real tool arguments and complete provider usage', async () => { + const usage = { prompt_tokens: 17, completion_tokens: 8, total_tokens: 25, completion_tokens_details: { reasoning_tokens: 0 } }; + await fixture({ response: frames('{"query":"indexed doc"}', { usage }) }, async (base, requests) => { + const body = input();body.stream = false; + const result = await request(base, body);const value = JSON.parse(result.body); + assert.equal(value.object, 'chat.completion');assert.deepEqual(value.usage, usage); + assert.equal(value.choices[0].message.tool_calls[0].function.arguments, '{"query":"indexed doc"}'); + assert.equal(requests[0].body.stream, true);assert.equal(requests[0].body.stream_options.include_usage, true); + }); +}); + +for (const [encoding, compress] of [['gzip', gzipSync], ['deflate', deflateSync], ['br', brotliCompressSync]]) { + test(`handles complete ${encoding} upstream frames`, async () => { + await fixture({ response: compress(frames('{"query":"GPU 文档"}')), headers: { 'Content-Encoding': encoding } }, async base => { + const result = await request(base, input());assert.equal(result.status, 200); + assert.deepEqual(JSON.parse(returnedCall(result.body).function.arguments), { query: 'GPU 文档' }); + assert.equal(result.headers['content-encoding'], undefined); + }); + }); +} + +for (const content of ['I will search. {"query":"a"}', '{"query":', '{"query":""}', '{"query":123}', '{}', '[]', '{"query":"a","unexpected":"b"}']) { + test(`rejects unsupported arguments without inventing a call: ${content}`, async () => { + const raw = frames(content); + await fixture({ response: raw, audit: true }, async (base, _requests, directory) => { + const result = await request(base, input());assert.equal(result.status, 502); + assert.ok(!result.body.toString().includes('call_research_')); + const files = await readdir(directory);assert.equal(files.length, 1); + const audit = JSON.parse(await readFile(join(directory, files[0]), 'utf8')); + assert.deepEqual(Buffer.from(audit.upstreamResponseBase64, 'base64'), raw); + assert.equal(audit.returnedSuccess, false); + }); + }); +} + +for (const [name, options] of [['missing terminator', { done: false }], ['token exhaustion', { finish: 'length' }], ['filtered output', { finish: 'content_filter' }]]) { + test(`${name} cannot become a successful tool call`, async () => { + await fixture({ response: frames('{"query":"valid but incomplete generation"}', options) }, async base => { + assert.equal((await request(base, input())).status, 502); + }); + }); +} + +test('upstream failures preserve their status and complete body', async () => { + const body = Buffer.from('{"error":{"message":"upstream denied"}}'); + await fixture({ status: 401, response: body, headers: { 'Content-Type': 'application/json' } }, async base => { + const result = await request(base, input());assert.equal(result.status, 401);assert.deepEqual(result.body, body); + }); +}); + +test('unexpected tools cannot replace the explicitly selected read-only search', async () => { + const raw = frames('', { calls: [{ index: 0, id: 'wrong', type: 'function', function: { name: 'task', arguments: '{}' } }], finish: 'tool_calls' }); + await fixture({ response: raw }, async base => { assert.equal((await request(base, input())).status, 502); }); +}); + +test('oversized buffered responses fail instead of truncating arguments', async () => { + await fixture({ response: frames(JSON.stringify({ query: 'a'.repeat(4096) })), server: { maxBufferedBytes: 1024 } }, async base => { + assert.equal((await request(base, input())).status, 502); + }); +}); + +test('an upstream timeout returns an error rather than a fabricated tool result', async () => { + await fixture({ server: { timeoutMs: 25 }, handler: (_req, res) => { setTimeout(() => { if (!res.destroyed) res.end('late'); }, 100); } }, async base => { + assert.equal((await request(base, input())).status, 502); + }); +}); + +test('health does not call the model or create an audit', async () => { + await fixture({ audit: true }, async (base, requests, directory) => { + const result = await request(base, undefined, '/health');assert.equal(result.status, 200);assert.equal(result.body.toString(), 'ok'); + assert.deepEqual(requests, []);assert.deepEqual(await readdir(directory), []); + }); +}); + +test('the CLI starts through a ConfigMap-style symbolic link', { timeout: 10000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'mosaic-research-entrypoint-')); + const link = join(directory, 'server.mjs'); + await symlink(fileURLToPath(new URL('../files/llm-compat-server.mjs', import.meta.url)), link); + const reserve = http.createServer(); + await new Promise(resolveListen => reserve.listen(0, '127.0.0.1', resolveListen)); + const port = reserve.address().port; + await new Promise(resolveClose => reserve.close(resolveClose)); + const child = spawn(process.execPath, [link], { env: { ...process.env, PORT: String(port), UPSTREAM_BASE_URL: 'http://127.0.0.1:1/v1', RESEARCH_TOOL_CALLS_ENABLED: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] }); + const closed = once(child, 'close'); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + try { + let result; + for (let attempt = 0; attempt < 100; attempt++) { + assert.equal(child.exitCode, null, stderr || 'CLI exited before listening'); + try { result = await request(`http://127.0.0.1:${port}`, undefined, '/health'); break; } + catch { await delay(25); } + } + assert.equal(result?.status, 200, stderr || 'CLI did not listen'); + assert.equal(result.body.toString(), 'ok'); + } finally { + if (child.exitCode === null) child.kill('SIGTERM'); + await closed; + await rm(directory, { recursive: true }); + } +}); diff --git a/helm/mosaic-stack/values.yaml b/helm/mosaic-stack/values.yaml index d1d0c328..a2fe067b 100644 --- a/helm/mosaic-stack/values.yaml +++ b/helm/mosaic-stack/values.yaml @@ -126,6 +126,11 @@ llm: enabled: true name: llm-openai-compat port: 8000 + researchToolCalls: + # Opt in only for affected research backends returning named arguments as text. + enabled: false + # Temporary full body traces contain private prompts; export before Pod replacement. + auditEnabled: false external: baseUrl: "" model: "" diff --git a/tests/docs/install-builder.test.mjs b/tests/docs/install-builder.test.mjs index 3ba30798..5dd25f41 100644 --- a/tests/docs/install-builder.test.mjs +++ b/tests/docs/install-builder.test.mjs @@ -24,6 +24,7 @@ const example = {inference:'external', baseUrl:'https://inference.example.com/v1 for (const state of [ {modules:[]}, {modules:['terminal']}, {modules:['slurm']}, {modules:['slurm'], slurmBackend:'vanilla'}, {modules:['bcm'],redfish:true}, + {modules:['research'],researchToolCalls:true}, {modules:['kubernetes','prometheus','alertmanager','loki','grafana','bcm','slurm','diagnostics','research','terminal','edit','clusters']}, {inference:'super',modules:['terminal']}, {inference:'ultra',modules:['grafana']}, ]) test(`generated selection renders in Helm: ${JSON.stringify(state)}`, () => { @@ -50,6 +51,16 @@ test('Redfish is opt-in and requires BCM, including when BCM is selected by a de assert.ok(args.includes(`modules.bcm.redfish.enabled=${expected}`)); } }); +test('search compatibility is opt-in, requires Research, and does not enable request tracing', () => { + for (const [selected, researchToolCalls, expected] of [ + [['research'], true, true], [['research'], false, false], [[], true, false], + ]) { + const args = argumentsFor({...example, modules:selected, researchToolCalls}); + assert.ok(args.includes(`llm.requestCompatibility.researchToolCalls.enabled=${expected}`)); + assert.equal(args.includes('llm.requestCompatibility.enabled=true'), expected); + assert.ok(!args.includes('llm.requestCompatibility.researchToolCalls.auditEnabled=true')); + } +}); test('shell metacharacters remain literal arguments and Helm list separators are escaped', () => { for(const model of ["quoted' name,revision", 'model $(printf unsafe); $HOME']) { const args=argumentsFor({...example,modules:[],model});