From dec9abb6c7d9b2e6d5cc74df440d4dcc8ce92822 Mon Sep 17 00:00:00 2001 From: Mathieu Garcia Date: Thu, 6 Nov 2025 15:49:09 +0100 Subject: [PATCH] feat(ansible): Create litellm saas role --- .../playbooks/saas/roles/litellm/README.md | 1 + .../saas/roles/litellm/defaults/main.yml | 23 ++++++ .../saas/roles/litellm/tasks/backup.yml | 1 + .../saas/roles/litellm/tasks/build.yml | 14 ++++ .../saas/roles/litellm/tasks/destroy.yml | 11 +++ .../saas/roles/litellm/tasks/main.yml | 50 +++++++++++ .../saas/roles/litellm/tasks/restore.yml | 1 + .../saas/roles/litellm/templates/nomad.hcl | 78 ++++++++++++++++++ .../saas/roles/litellm/vars/main.yml | 10 +++ .../saas/roles/litellm/vars/upstream.yml | 3 + ui/public/img/litellm.png | Bin 0 -> 12592 bytes 11 files changed, 192 insertions(+) create mode 100644 ansible/playbooks/saas/roles/litellm/README.md create mode 100644 ansible/playbooks/saas/roles/litellm/defaults/main.yml create mode 100644 ansible/playbooks/saas/roles/litellm/tasks/backup.yml create mode 100644 ansible/playbooks/saas/roles/litellm/tasks/build.yml create mode 100644 ansible/playbooks/saas/roles/litellm/tasks/destroy.yml create mode 100644 ansible/playbooks/saas/roles/litellm/tasks/main.yml create mode 100644 ansible/playbooks/saas/roles/litellm/tasks/restore.yml create mode 100644 ansible/playbooks/saas/roles/litellm/templates/nomad.hcl create mode 100644 ansible/playbooks/saas/roles/litellm/vars/main.yml create mode 100644 ansible/playbooks/saas/roles/litellm/vars/upstream.yml create mode 100644 ui/public/img/litellm.png diff --git a/ansible/playbooks/saas/roles/litellm/README.md b/ansible/playbooks/saas/roles/litellm/README.md new file mode 100644 index 00000000..dcb700f4 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/README.md @@ -0,0 +1 @@ +# Role: `litellm` diff --git a/ansible/playbooks/saas/roles/litellm/defaults/main.yml b/ansible/playbooks/saas/roles/litellm/defaults/main.yml new file mode 100644 index 00000000..fd899ea5 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/defaults/main.yml @@ -0,0 +1,23 @@ +--- +# Environments variables +litellm_env: + - key: LITELLM_MASTER_KEY + value: sk-1234 + - key: LITELLM_SALT_KEY + value: sk-1234 + - key: DATABASE_URL + value: postgresql://llmproxy:dbpassword9090@db:5432/litellm + - key: STORE_MODEL_IN_DB + value: true + - key: OVHCLOUD_API_KEY + value: changeme + +# Default configuration +litellm_config: + model_list: + - model_name: ovhcloud/gpt-oss-120b + litellm_params: + model: ovhcloud/gpt-oss-120b + api_key: os.environ/OVHCLOUD_API_KEY + +litellm_dbhost: localhost diff --git a/ansible/playbooks/saas/roles/litellm/tasks/backup.yml b/ansible/playbooks/saas/roles/litellm/tasks/backup.yml new file mode 100644 index 00000000..ed97d539 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/tasks/backup.yml @@ -0,0 +1 @@ +--- diff --git a/ansible/playbooks/saas/roles/litellm/tasks/build.yml b/ansible/playbooks/saas/roles/litellm/tasks/build.yml new file mode 100644 index 00000000..68406697 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/tasks/build.yml @@ -0,0 +1,14 @@ +--- +- name: Include upstream variables + ansible.builtin.include_vars: upstream.yml + +- name: Set custom variables + ansible.builtin.set_fact: + image_version: "{{ latest_version }}" + image_name: "{{ image.name }}" + image_labels: "{{ image.labels }}" + image_build: "{{ image.build }}" + +- name: End playbook if no new version + ansible.builtin.meta: end_host + when: softwares[image.name] is defined and softwares[image.name].version == image_version diff --git a/ansible/playbooks/saas/roles/litellm/tasks/destroy.yml b/ansible/playbooks/saas/roles/litellm/tasks/destroy.yml new file mode 100644 index 00000000..e00b7de1 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/tasks/destroy.yml @@ -0,0 +1,11 @@ +--- +- name: Stop nomad job + ansible.builtin.include_role: + name: nomad + tasks_from: job_stop.yml + +- name: Remove software directory + ansible.builtin.file: + path: "{{ software_path }}" + state: absent + delegate_to: "{{ software.instance }}" \ No newline at end of file diff --git a/ansible/playbooks/saas/roles/litellm/tasks/main.yml b/ansible/playbooks/saas/roles/litellm/tasks/main.yml new file mode 100644 index 00000000..6bda2d69 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/tasks/main.yml @@ -0,0 +1,50 @@ +--- +- name: Install mandatories packages + ansible.builtin.apt: + pkg: + - python3-psycopg2 + +- name: Create postgresql database + community.postgresql.postgresql_db: + login_password: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='passwd', missing='error') }}" + login_host: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='service_name', missing='error') }}" + login_user: postgres + name: "{{ service_name }}" + encoding: UTF8 + state: present + +- name: Create postgresql user + community.postgresql.postgresql_user: + login_password: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='passwd', missing='error') }}" + login_host: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='service_name', missing='error') }}" + login_user: postgres + login_db: "{{ service_name }}" + name: "{{ service_name }}" + password: "{{ lookup('simple-stack-ui', type='secret', key=domain, subkey='litellm_dbpasswd', missing='create', length=12) }}" + state: present + +- name: Create postgresql privileges + community.postgresql.postgresql_privs: + login_password: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='passwd', missing='error') }}" + login_host: "{{ lookup('simple-stack-ui', type='secret', key=software.litellm_dbhost, subkey='service_name', missing='error') }}" + login_user: postgres + db: "{{ service_name }}" + roles: "{{ service_name }}" + type: schema + objs: public + privs: CREATE + state: present + +- name: Copy nomad job + ansible.builtin.template: + src: nomad.hcl + dest: "/var/tmp/{{ domain }}.nomad" + owner: root + group: root + mode: '0600' + become: true + +- name: Run nomad job + ansible.builtin.include_role: + name: nomad + tasks_from: job_run.yml diff --git a/ansible/playbooks/saas/roles/litellm/tasks/restore.yml b/ansible/playbooks/saas/roles/litellm/tasks/restore.yml new file mode 100644 index 00000000..ed97d539 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/tasks/restore.yml @@ -0,0 +1 @@ +--- diff --git a/ansible/playbooks/saas/roles/litellm/templates/nomad.hcl b/ansible/playbooks/saas/roles/litellm/templates/nomad.hcl new file mode 100644 index 00000000..4e805497 --- /dev/null +++ b/ansible/playbooks/saas/roles/litellm/templates/nomad.hcl @@ -0,0 +1,78 @@ +job "{{ domain }}" { + region = "{{ fact_instance.region }}" + datacenters = ["{{ fact_instance.datacenter }}"] + type = "service" + +{% if software.constraints is defined and software.constraints.location is defined %} + constraint { + attribute = "${meta.location}" + set_contains = "{{ software.constraints.location }}" + } +{% endif %} + + constraint { + attribute = "${meta.instance}" + set_contains = "{{ software.instance }}" + } + + group "{{ domain }}" { + count = 1 + + network { + port "litellm" { + to = 4000 +{% if software.static_port is defined %} + static = {{ software.static_port }} +{% endif %} + } + } + + service { + name = "{{ service_name }}" + port = "litellm" + provider = "nomad" + tags = [ + {{ lookup('template', '../../traefik/templates/traefik_tag.j2') | indent(8) }} + ] + check { + type = "http" + path = "/health/liveliness" + interval = "30s" + timeout = "10s" + } + } + + task "{{ domain }}-milvus" { + driver = "docker" + + template { + change_mode = "restart" + destination = "local/config.yaml" + perms = "644" + data = <_461Gk z38StQ&+n~;(E}Ei49N2GTNphsZ-Fce5Xe%~gSxW1Th)`)Q_VwWRSppu)4k{H_s6+6 z;znlHTp5{Fo!?rK5gBpg-gD3SV()$S*?S-1HH3-DDMT=;T1MHb$PN*9qIx^Xtq405 zwg`-)I;_Zu2xXwGr~n9}KueXTpbbRk70wAvE1VEGqVTG~Vc>+qR3O@RDsJDNEvwxH z1B0w~16B$@_I2gW$rP!W7}ZSzuLIr$>;~>ebtlLcgb`FjkN^P`RPv5dwA)d=cO-j9 z^A7C30Kft%Fb7Nl$ARa8XH+?a@CqVlMcW-@n0@z+t#((f2yQ51ed6F0fKjJoAa^0W zUEy1RdjxI)MgY_Qt$tQq8{~wP-kBArP(wHh91?g0)yEWhK@9VN+jrlMBhjvVVoe;J z0@R?g8MqJl4&dD=Z$KDVR3sBYUfQhAEqAZ2ppw7V85Ma>;9*t1Bru6Eja>gEyDo|K z8&6J=E(oTqBZ%Is$ajk;)Lp=kmlmr>qRT_3dtm{10pW`Ze}VGUm-cL_z30F*``)&B zwHtZaxE^}Gi;o|i#)?Sn+^xVr0)7zXeig>OM!R0fv>bZY9Lg;4xT60r$QOj*7^vH~ zchhP&@{(~~jKai$DG&nC-KhKs!VjRlK}AEZV=7$?s-(a-1wN<3=TM$QBza)Z+LvH! zdQ2uJ&j3Uq_X7VM^u4I;QWWIIB~(AWs#M_w%AX1R0qB!JvTyI!)o$E*u_ni+C!yF4 z{1os*z_!(H*Xp8ci=6`g6!;X#Q-IsIckM~AHASpnpFBrmj8t558}L&KKjz&8E8!Bj zUXk^Mas>E8gg>MquTT~C?O8iE*qRuviGx$%1VqOben{Zwfj23bHPOH|2?Z60@C?fT zjqu-aGE1PW1y{wYkK6vqDG&)zeW$=L1K*{pF>)iD<<|d{>N zDK3tO4o-9P2S0!ydx6gY{~BE<)yhHus)+t;kk2BrH(mIKJoLoL!CieN@zCTnP9zA_ zeYe1GpxnK#`Bnj{&m;U2Mn10&x6-r56_r>MlV=GqQK0T$0G|R~C)H{}1!SUIV_^bpV-DwWaVMfQJOBt%SepnvZsYRKE)RJgPD4l4@N@%7o?^$0=HF_eE7iTj=~ zwOp-QykZvYpPbHff%k%bR?)lH7n84dC?L-PKMwi?81`LZ;>J}-EJfjFMSlZjom4jj z1eCjhPk`(Mcg1JAB4X{I%tK#zP~bhjPR2TJ2>4Ru6H8FXPq0vnu z=#K$E;T^f_(&~nSmlHoBxc7GwmHk(+u6P9!%hH&iz8`o1w6e~r8xSIZR)7zcBHoEQ zCZ0TVS*N%xVtFShn-xB+uxnjX-N+DtUBE{Xwy3+jvA$$t?LRn;8e;_hmB4qcQ|h$< z0r+--52!I_|G`VQw=RiT6LX5FVw}4l_-R#w)xPC*3E>{xfKuRIi=b#tW;S9pCj5MNhpyC}bevY|Y(^U}U|u>&Tao`a+fA|C*L zOI1tj0ejaD=NtH9Q31(HR|-BgJQO7q{KJ4j2@M3I-QxmLI!-vzGFX*svC7s__}M`9 zQ!Rv#?!9m%M0nBDr3r|+1N3KDC)HJBIU;%Dc(OzWN(M?sDI2JUO4%qS1Ccl)QDSj~ zq69b$4pcEHlD$?{L@npikkjoASiSl&Q;0?N=bZ3YvFa-qJN|-KoS2*@E)PqmRr?t5 ztLw!%E+6M3QY1<#fRPYJLl_Ai!-1n}95I$aQvxhTEvUs=i*pur7N^cXJBL%}pKboX zqE1n@%*hZ>g>N8{tIq}YBd>t$SM(#og{we^7du_2Rl5&(f1YN$j!Q@GJI>WpY?RSJ z*&I7IM~>0ZQ8kuO6VxW8oi;X2NNq|xO*~PY^=HyT#b!B=Y7@a2g9L&xF|k&B>7#2P z4}AER0Q{&Re+2sU#g1PV6O(5^2vPYhg%7S%>hhrqkx@2=md&QcM$=}57NK@f+re3j zbzT}NU`#*|gxMn?2m*{T*>ix1WXA-E7>p6XcvUKX9wr&-1jFgJ@cGjrZFfyniZFkxQ#evcUF*<3#OqEF>*zHuW};@g#|&kT8gd;+QxN zyYEF&Occfp506r=458}BU^#+lSKoU3O4W~6XmtgKO+n>-z<&XG{Cr0(ORW6|rchs` z`#>^DXDb6{H{b@u%6h*UQVBX{i0z()GJ8#~~yY7Dn zTeoh@&h0Yt^pk1H^?^pmpgP30O&=A2?Fj!2(I+M*r@#J4rYxn>pFEv^puNb=XX*6t}_X@8ZJ1pF4$DTIcw}<1g~Q z;W0LE+MG$f;;~s}dsVvX&^g>?uJ2u5C&(_)A1MX$^sgT{!$%g`T1zFCI_A$z?*iVJ zWzG(6#MU^9l0gJwQL3br(hjE8pt;aws@7n3p+miy&`ca@_8kc$N|AU2TqHBHrE6?= z>lb$W+dn&1+~_c`*3a=b2Vdm7?vJQehqKvYf7?_GYEm;|tyHL-=kxcst$r$mSC{*^ zi~R?u5T_t~0OWUIeN7Mw5=#6pv0@xUE+K3;X)n~7n{RNg)@H7m&`KRv=@KX~CY#wA zj0nbL#L6U;h#@bFS^N=eUc*-XCOp}7ErA4-tX2f!t@vfY;CBD+w zN=MDgc#0IO=+A)s9-=(3rzf?-MF*lHBLeT`HBPFcx)bp}s9|vo**0#z&fHv`sksI- z3q>*+!Z4&11O!Hi0z+T|Owc2kCtD_|5JWO}L9QC1Yky^m4d=i*#g=a2o3qC$Kl2jz z-F+Kj7*RCdRsH0^t6H!wVC6&>AOxcPpu&GerB?iCkt$R`-U|AT*RpMu3j!lhHp(ao zQM*BNzRr=E1ms>~h<7<;Mw(=9RPDNU z{41x5bAojab!FU8nPc@6j2)Zej+-}wNLK74SBKgqmUFt2wDNrg_k-RG@|EKIU1Cif zoWdm*69nG_vf;JHD=p|dBQO*wquN0h>del}b7p3N*+z#};y}F9vK$6PK|mY@#M$dG zCs+_UV%Zq1)dw{!MD-*$@${Pt`lSwcOHBO(I;rQ7F&MhRQsstvWTnZ>f0i`G; zjzUV=BMyV^a};^06$M@$27#AIs*t29%~pqIvqhuPpjKaCVPS#BLW5?bNxRh|X?L)x zCAALc3?Kx7bdCQY5K3V{Y#fmo&d#)$ueaGWURg4G&n&n>b>&@n?tCGCBLu&9&A$7# zFflpx9)zJA-M-R-QC8KL~zT{#ZABq~P{u^| zD8~_T9D3E7OFjc(AdHU0l;UvFtk2tMjWk9YD?66i*@u8{Z`JDmbK<}m-M4pZCb0x2 z4o-~&-+Cj5#uVKKB?Fs7%Sfle*^_5^<OX|A9zt9vm)m3Nm$hQ}3VpsNZMg*PHM zZbox1r2+$+LS+k0YE$Ppc=RkM=Gt^D1VKO?hm@m`avV^OV^69$q7p}xN}f!p(rUMu zKHFfvR--mwr(UnoZnY4bPzr^yO2}>7%4`|m#OCoT*e^1N5he&S71krl zq7hs2{`rn6df)R|p$P_36g!XAdV?Zw6!cNZh~*2s$vc1>ZZ??D_C$oSP}v?N*tr>= zKRV4TQw!8P4wDh86lT(?6jF|3DwzZ^B6K3L7!8!=p4pIXcT!J;8|~3L{EUKsoNI zx+n@U2=zvb=E4H4dJS#YDVq)><$%|1D6{2`O>EvU!iJFwL)Dl#iU@-MF+CCiOI|M? z4(EDN3jP>Va6=Sd2U+};EFw}doJP8&L?Kp}73~|qxcXXK-=dD+-G5*z2}Kmp&A>f3 z3axS}6^gL61h` zwr$+X=J6p$hRc*oF;UnHzC)H9@M3#hSJLD}^F%S(IZQ^P;yLI$XTKe#ELMU3Iz>sR zth{@)aE}Or-XkKLK~IL+rEeG5dA-|L#qgEU5bmrh+oa9m6Vn`cd5Xj5TC`G#qL4U_ zh{KS;7?Lz4X)jPBVM7UaY>BvK(sq)iZn z-N?{f^7P2Ec;Iu@)>C1*2%APLR7w#+7+}O?s;#Gzde?N>b#p&ce@JofJ6Xpsn$7k8Uw&3+j8|(XPP{^+ zQDbOmWSP;)7qW#U=`cGxO}Sjn+NlE_%Zxi?0&fEOYM8bo2};TC^Tp?{b>yUq4QwkZ zuPb*LNoScmJ;l+fSKQ>vMENodztlx*LcBf>E3Q)N9HsAr`2#aAtoMEw#^&vQ@xHd7cz z1VL0BzC!Gk>yqBu6z43RPCFx(qtgM!Fkn4&H?gUPqHF>N5z!Oua>Y zlB=iAvJe*|OYEm!dzOhc@6OzM5uJV(=Rh6bJY46G{>LefoKmad%-0&s&o>bfHtgKUy+5*zs642cO#xv9*cqbS zrVy@W%RQG;kx_0fCEOL)kcDZcW~XR0Y8Yb}85yTi86k>FT{TvePC%BkS0ajHj0wBy zt=|#3)JxuLhfiHE#+T<)|2%8CNTj|PbfCVF@)ut|$Fnaxi~%wUr7AclIP1wM`0v`( zk~HA#tR)B?MtY%uzDz^z4D}&0VE~{^E8l_aKbR|%6mX?z#j!Ru%Qc)@`wv`qWspm7wY9wR? z>JiEINviyNi|ztv9bY>%&!HDv1O^c8+E|4vA~=N9lZzC=Vk9LnA>u;;{k25BA1t$A zMDb)*#gKv+M6&e!0YcCKxHUv{$6B?myy}A?O&Fh@qH4}@HaSO<)G3upjE`@jQmJNA z$`i`N5QZ_OQYDj4OP5F%d$oJDrE>L^lgSt3@D9qNG&&!N`ZJTk|1eIRY4PQ+)o8Y@ zk5b6U+Ix1s_=j_-bEsNusy^1;YR{RPaj6%R-&;gfgv1IqRS<`|0s9>Cn}r>rs%&0s zYB0}G2?R#d7VVdh@bH1J^VZi3x85?$$jCS&BcsG|nJDtcdG4%?<8mgw;;wzvo7pY( zGl6Cq$~_|4tg=fknfz?A7(LK!X`#)!=zqGg=t<&u_+X7ACzC8W)mKJSdZcnL&8o@x zXEduUV_mnxOs@7cpG_7CtYA!-$H|2o9!={h%U-NM$bTNxP{Aq>MV zVdA(#xjaM|21{miMTesAc-!@6dxv5sC3HCg62!e=W!_@=qgC8%;hi~B2pdr z)t75fMyfJOWqOLIzw#81KK2b3+OTQcF2=^o#HBJ}=+$1SRH0HC>NCg}J1l#uEiI6# zf3{XsVLbx&|G!uoolj!*zo)2!L*JO^nHOuQlWtv63YGVKQMHUnMd_su=MvO;SA=GD zvU2sQ&UGc1_j{!nBJU5&B$d;_8}AuLLZGthMb)zF$0()gDW3Z413Z4oKu1zq*@&%j!S*^Qm*d2dhIqTC}b5RCq5vduq}8_ichrJGiui4cp|!*K)h8k6_MW4PJfP#9zy$ ztHUdy90KLlUVc^)LX>D~ipT%QqdfKWF@nk%V`Cc$!!pK11d(BAc$D$+&4oekNhZ~_ zOQhUx>L=5pSfxIvCVijf1n8G!%fGjjWd1$p;Nb()ym)v4QOP7F1y$yHOEO(4WebwX z;nIXO?f4~~Bq|*53TiMvk0MsGd-szBXHy0b0Vs#7EUQqw6D>M-jsu^6l&79PN@;i_ z)zJ+EK};CLgmIZt91=$n(3|Qzr(H66LiLkru{V5qNu`U31F*ayzMN6tZ(I3ejKJaJ zO&&QoLy{q9c-9%C6wCSC@Hk*P>{*9bbNInlsZ)!oUx(bJAU0?z#-RKZ4xl zVEb*Xw4L2!8`Eibk5yNgRifGd4l!glWkD6L62fk<;(#yQ^{6Lpjy(D_Pdxr2@z4gU zqhpvLB#a{BQVC-O=MqxerqymztIyJIx3O8t#l=Q=kx2d0i^AuNiE$yf!uebb%fBxI zUw@&=u}i2PbP&mKG!#!Ba3XvXM#?>jb&}(A4!KaX!j~Qbb}0YY3FBxCJ@w# zCzg6uIA{|qGf*@HTBueAPOYr+-Q4pp@wKl$i^L;ThDUvNNf;7^0ctxWos>?J5V(12 za{;w^;|pYD-tonfsh<#+VxTYQRyaR#mVQq6IwCMV%Xc76+3D!GKc;cyfUOid|6q{PV^+;jCICl^96DpHh&U$I(9Eu}N+L_eywpQ+K z%wxE;u#TC#E1ZfaC;ohcpA`x~GgQ^Oh-?`2^n!zu>C@yW$Nn5@%ZJG^2<Cw&~CM8 zHY4)Tm7WUn>;8wIJ;-?N88%iFC$SI9HNhfxLqP(m%l4;n%%^3}ZhSKvx9{*%{zabl z3)y1elOJr_;4OCryrCUoUH2BUog$PVaD;*F5+OTdc7}{7nf>GxY?5;1@H|lrrAmNv zK{qzVWe!&nKMQqgDV2rMkr7&rEd(Z^a?>bbxEgbQnGaQ%5gkm%Y(LchVkN~UO;RXK zw{!K-n~6CrtyYsp!yuB+&U!>iQl&NhDsLD)#;zUP2&zL!5F$a01SPid}(GAolRSCw$ruW{B?RnSV}T4t!lLv=Vn_Bl|w3Vnjc#5wtEuD5<&^* z(^qMtv=&P2yk$GX!=pXvn}_psow1J8TC7#9vvjIuASM`HT7_oiM-@ak7Ya%q0<(%{xhxHtlw^OX&Rki(eN>)#)TW`uHmx`lh3uRQpwx6kH75 zhy*RDug<{2u((}Y3X4V~#lH*~uaRmaHKRys+*%-NHuI=0=};QQWX zDaV5xRREn1ffIwfY(Glnn0Bj1t1(LudcT#)$|m*_0{kd;RB1FCEYvMQXuF>kZLD~x zj1Jw!Op<1XZE@g=QXyAwz3+x25rIIhHb_cU0vHqiyI>q2-Nrb0t4f+bEiql z>C;szl`>K6ckn9q30YNU0O#`JCMDV}pMw}}zL_W*_^?{wNT?#O2&C%c>IRC{BQ51SzF+RM;@j&a~f;gU^OF`>DEKHCZg49(QG)< z7=oa`&OvX$dQxaHmfzs0*e&#tr#SQMIb-|3bAGa(kIa`2=Ug|RFfSLm{BhaL**Y4H z7S?)+9<@4Dbg-%RsLlh2a~tanuxXIj&oQ)R18@DF_fRTVdG6tdnVmjP+NtBx1Vl07 zJ5&frl7wbUNv$VVFNw5xU)mST-U~;D#i?71T?qLA`OkZ*aj~;B|NH{In@J?;UaQ-J zWyp3RX&T27Ns^ML_9A(VigS%#`<;Viuxo{x)IANH2!Sb(V*+;%v=zQLK!l+!8+gn2 z{zJxZx{2o>evH$HU!=Kk4x2Pk=U4Sb6lX1+wofp~f4u$K$Guc{WAZu+dA?x(Ws6cM zCsbamsyN$R>bF`-mP@!%=y(5j+j0J`IX@YfBcd*S!!LaeZZv({-~=L5A%qRE&jH^t z;5KAEP*6+aijDQYofVi3ma^2>R`w;wk;J zEV7gO3FX(<@_fL>;V{cdw58-+Y|j;KpzNq#cP}%( zYFnWF?9rKu%NLBwU!Y_ajaSaL0vApP!&Vr+eGO#9D^y}7(iSTw*dgv$v* zQ3Z?;gaLyZQN^z|Aw1L2Kz-pgm1hO!1jYu^ie*r|{jgeM(_OpRaQk*{pW4m*(c`pE zA7w}NG$TII8H_+Q`@OFr|p$om&~+u^H{$P02MV z+d5IQ0G&D}sUztEbQW-F8%;aiB^!0U9Kod1q0>p|WKo=9CXke-`j?zyL# zJ=Y-hy90F17Qu@wRd7f*%tc+6%8q91d|4M3I8;oJ&pNlz)j+}FYD9@c>o46m<6JtC z=ubh*7pMF8kIY>RK8Mm^t)((N%Io&LlhV*IR5oCng>iZnW!oyk8#FC_W60J{@ zcg~R{X}3&3(FSa_+8ASqPP3ex^Ub+ozK}A9NJ0~W2V9%Cp0|DR8 z$3bTl*|1i*b>Vht_is;Tc;jX6r5AbW;m0|0yh$@1CI~{T%j%0d<9U)rTWBvb4~iMf zc^mQ+`CNkZUBBNMnUl(h5=j$d8mRG$>P8G0AFnQfFAI(ll6IR`vqdU?>lDr6w2VmC zLE3Fnwq%s1mM9FdVTwyql2oxK?k=RS^mt}XnE@V`jP$)^3egupz5(37!tGd5EbheQ z+!QAsdz2%G&(X5wUV^I$kiZuS>BXPt(G+&QzU=IpQOtRs#h zD&;b$GPY?m<>65llY9Vu{v7Z^{$941jxZc=o~^g1zbbJ5T33gcj82PlUw@KsK699Q z+n0tE(<2pG8h&@3E?cxy1X1k`Omjj7|5N@ZUT+yq$bh(lkGUV&Dtg~2d9 zRLzR07&hH<2bJ+nSGSgQx$xKeyM24M%uY;B0Y3YReS5Z0Z%-@mSHQx+U250*KC{0I zhhOE;R}L~glOiVaYmdfwNtKm0LIfjzS1rFKT=vNK9t;AaAS~>f8f347Z0k5**EGyZ zQTn290a0j(LLrnUk)1+F0f!jBeM&oVY`XjHl(*f6iOW6~20?<5;hSH__T6tINgZjD z5`>YT#j4V3b!c}In#~qzVi_MBWq4=^XB{Stx#hlh5mrYB-Q^3wUr$U`9bMt9)@gOV2kHqA9vF#oYKVh;PYd)8fB) z=_P;)MidcClFVZ|vy6_0RI9_3N)e4llO$;~cJm!ZCCiDK^jwtvEjLz6Vm z9K~A8=*Te64Ur^1mr_K$Bnv}o^*YT)i>e2!=Ov&-cBb>eijht1MMEy>2&qe)y+&>_2~! z`PpflwG0mpQ!bYIbTIvu4-pACjj^XXMv-^90l32?qxuQ531=p+eN0&aTKeeC_=f63On-rAFZ1Bi9c?9v(F`#`?BZ||1w zXUnA6%ag!kz&{!s6|NmznsVa#muWRpqPUz%t4yu=>adxL%Ls#XH~%)p4Z#CLAl}Cn zEZSV4J7jtIadtj`dP|Zts5jfR(|{Jf{>1RMZQQ>5ZQSy7U%no!|Zgbn0{1Bq1!7h=<1r%N2@^8)#OJss;W8sF_8sge9%;>X6%j zKUZP*^+c@xU#nu16r0*bcFd*O9~VAKF8B>&3}LC1?Gn~)+%>vODMaC{^88)(Ip7}y zhx6HBjwPxvMW_ayS35TPGf{S5Pd=^18%+jLL=Z(+e7>uNI=2E6L|2_pc{4yh=i0R+ zrZlwV<0V1VAKAN^wu?acBZMQX-TvztgH9G56g;Q&IHI33}_KmD#&EX{Q8IW|bZI#2E|GNs} zCV|hbORaVEpaB0J)yWI-;=IJ#x929%AO$`R>$cW9GAM_EPh%uq&bG>7E_xo}5Gwy= zU1+T%iyZt6sUEx-A1+9&eS5YbPMpH0LBDwW8-Pi&k&;Lv#oMmRAO~71n4U& zd{SWWCF0hxHlVbD-$sI$Nt25neoml)wYl0i zzm64xAKU%o@z);X3M`*1T7KVu;0!=jg#Qzj4_;59HS4$pcoq1&iu*;N@xb0~mwk#W zro{dOQwZz?{X>NJti}ce>sSdWs(cCfap3p^7jcB150|A1ix6=j97FgJ@Z2EtBiFHl zP@WgzmjyYFxGO%*6%%XUo}2I~-oX>9{EDEbSG!NvaplM|jZP~3Ql;V!DDc3Z%U6Lp zu9#Q=`}S;wz~RLF1;Vd_&aHL_t>eld=q$p=oTi^|G=v8Vsa#irt0ES_gL}3R_#OHF z0GI%6uXZP`<1(Sz20kv>KM0I*m+M+s4p&Vq@M_Uv5`|By@|)=TV)bAkPYU{wpp7e?0WODY?jy+Z5=VfK z0zZc^z`du|ami5a0G|Z?HK4Zd8mPY_R>b)@&mkQOE!hZnW zFED?tNd+tD)ts30-g)4Iz(+t=M{e0 zO6T*2z&^O=(nDab1lMdOAU~iWQewIeul& z7RTpF99Eupz|m$)NAEUzZ|UUNbdK})Xkv(3ixRecC3@@^3mOsLpcfj zHt2suWlBQvQNOEzRUgHN4osm)U~umccmUx$VDS4>U$4k~Qyr8qDEuEv`Y0It;1xuD zUQ4Vxu>dBXn1aB8lW~L}M)_HU-C4o%bu5FdVzC3BMffe0KNiERT4CSYR!2$|u_lO> zW8%Oms0cLW4utmuKLPAoW6fLxP(Y5N`dLLk-KPF(D6xKI&n>InurAi*h)qnM1~4FR z1^y-QgTS^m+T4MlyVnn#Lil6g(;&}?Vn1}1L3t~IH9b;(y-_2Odli0C;QQgVW8io9 zg7Svc5zs$T<-ZF0BuKJS>wMP&*To3!KX3+wNZ@YZeZUU^uLlVSv3uyC(Y+_{I`{_g z-x2vEfoD{)`_`a@T8!&zOeQ8z;Wr8lZo%)I@xKG_Kp7vz79T5#9+?1i7QZ{lp9250 zL;G+*pda45W3`)k$+#{@V`B0&)vx}EEWG=@!-BjO<$Dmm6L=#qlyBO*)^Dz->Jsk9s#}vJdZL5xP5QCzR6U?4MnUXCJr7$?Fg85EXpR} zc7%ISb^~_-Hv?M{MpX#~vdx;ww(rf6ZR%9`ub{+wc2RGC8;g*l)PO1A81Q^Xre}ed zRP=11apzF{=Y00fKepOkwIaA-iM1R)ao`N78a0$q*#hiP;Z~H}1a=|pP-L^HZUja_ zst6UJtlbSe{C3PJEzlZJSCLskr%*WodKlpqlw-gNgfrr_g^2sm8g>s{YyAIg4#d