From e499edba908dc7deec269374de4a5d2670ff3dcd Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 27 Feb 2026 07:54:42 +0100 Subject: [PATCH] feat: initial llm-connect package scaffold Copy markitect.llm module into standalone llm_connect package. All markitect.* imports replaced with llm_connect.* equivalents. LLMError base class inlined (no markitect.exceptions dependency). Verified: from llm_connect import create_adapter works. Co-Authored-By: Claude Sonnet 4.6 --- llm_connect/__init__.py | 67 +++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1891 bytes llm_connect/__pycache__/_http.cpython-312.pyc | Bin 0 -> 3501 bytes .../_token_estimator.cpython-312.pyc | Bin 0 -> 759 bytes .../__pycache__/adapter.cpython-312.pyc | Bin 0 -> 5429 bytes .../__pycache__/claude_code.cpython-312.pyc | Bin 0 -> 3748 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 4237 bytes .../embedding_adapter.cpython-312.pyc | Bin 0 -> 1556 bytes .../embedding_cache.cpython-312.pyc | Bin 0 -> 3544 bytes .../embedding_factory.cpython-312.pyc | Bin 0 -> 1981 bytes .../embedding_openai.cpython-312.pyc | Bin 0 -> 5288 bytes .../__pycache__/exceptions.cpython-312.pyc | Bin 0 -> 3973 bytes .../__pycache__/factory.cpython-312.pyc | Bin 0 -> 2413 bytes .../__pycache__/gemini.cpython-312.pyc | Bin 0 -> 4705 bytes .../__pycache__/models.cpython-312.pyc | Bin 0 -> 3716 bytes .../__pycache__/openai.cpython-312.pyc | Bin 0 -> 5249 bytes .../__pycache__/openrouter.cpython-312.pyc | Bin 0 -> 6296 bytes .../__pycache__/similarity.cpython-312.pyc | Bin 0 -> 3634 bytes llm_connect/_http.py | 86 ++++++ llm_connect/_token_estimator.py | 16 ++ llm_connect/adapter.py | 169 +++++++++++ llm_connect/claude_code.py | 94 ++++++ llm_connect/config.py | 108 +++++++ llm_connect/embedding_adapter.py | 34 +++ llm_connect/embedding_cache.py | 64 +++++ llm_connect/embedding_factory.py | 50 ++++ llm_connect/embedding_openai.py | 125 ++++++++ llm_connect/exceptions.py | 85 ++++++ llm_connect/factory.py | 60 ++++ llm_connect/gemini.py | 115 ++++++++ llm_connect/models.py | 86 ++++++ llm_connect/openai.py | 129 +++++++++ llm_connect/openrouter.py | 139 +++++++++ llm_connect/similarity.py | 64 +++++ llm_connect/toml_config.py | 271 ++++++++++++++++++ pyproject.toml | 21 ++ 36 files changed, 1783 insertions(+) create mode 100644 llm_connect/__init__.py create mode 100644 llm_connect/__pycache__/__init__.cpython-312.pyc create mode 100644 llm_connect/__pycache__/_http.cpython-312.pyc create mode 100644 llm_connect/__pycache__/_token_estimator.cpython-312.pyc create mode 100644 llm_connect/__pycache__/adapter.cpython-312.pyc create mode 100644 llm_connect/__pycache__/claude_code.cpython-312.pyc create mode 100644 llm_connect/__pycache__/config.cpython-312.pyc create mode 100644 llm_connect/__pycache__/embedding_adapter.cpython-312.pyc create mode 100644 llm_connect/__pycache__/embedding_cache.cpython-312.pyc create mode 100644 llm_connect/__pycache__/embedding_factory.cpython-312.pyc create mode 100644 llm_connect/__pycache__/embedding_openai.cpython-312.pyc create mode 100644 llm_connect/__pycache__/exceptions.cpython-312.pyc create mode 100644 llm_connect/__pycache__/factory.cpython-312.pyc create mode 100644 llm_connect/__pycache__/gemini.cpython-312.pyc create mode 100644 llm_connect/__pycache__/models.cpython-312.pyc create mode 100644 llm_connect/__pycache__/openai.cpython-312.pyc create mode 100644 llm_connect/__pycache__/openrouter.cpython-312.pyc create mode 100644 llm_connect/__pycache__/similarity.cpython-312.pyc create mode 100644 llm_connect/_http.py create mode 100644 llm_connect/_token_estimator.py create mode 100644 llm_connect/adapter.py create mode 100644 llm_connect/claude_code.py create mode 100644 llm_connect/config.py create mode 100644 llm_connect/embedding_adapter.py create mode 100644 llm_connect/embedding_cache.py create mode 100644 llm_connect/embedding_factory.py create mode 100644 llm_connect/embedding_openai.py create mode 100644 llm_connect/exceptions.py create mode 100644 llm_connect/factory.py create mode 100644 llm_connect/gemini.py create mode 100644 llm_connect/models.py create mode 100644 llm_connect/openai.py create mode 100644 llm_connect/openrouter.py create mode 100644 llm_connect/similarity.py create mode 100644 llm_connect/toml_config.py create mode 100644 pyproject.toml diff --git a/llm_connect/__init__.py b/llm_connect/__init__.py new file mode 100644 index 0000000..5d7cbe6 --- /dev/null +++ b/llm_connect/__init__.py @@ -0,0 +1,67 @@ +""" +llm-connect — Pluggable LLM adapters. + +Provides concrete :class:`LLMAdapter` implementations backed by +OpenRouter (HTTP), Gemini, OpenAI, and Claude Code CLI (subprocess). + +Quick start:: + + from llm_connect import create_adapter + + adapter = create_adapter("openrouter", model="anthropic/claude-sonnet-4") + response = adapter.execute_prompt(prompt, run_config) +""" + +from llm_connect.models import RunConfig, LLMResponse +from llm_connect.adapter import LLMAdapter, MockLLMAdapter, ErrorLLMAdapter +from llm_connect.factory import create_adapter +from llm_connect.openrouter import OpenRouterAdapter +from llm_connect.claude_code import ClaudeCodeAdapter +from llm_connect.gemini import GeminiAdapter +from llm_connect.openai import OpenAIAdapter +from llm_connect.config import LLMConfig, load_config +from llm_connect.exceptions import ( + LLMError, + LLMConfigurationError, + LLMAPIError, + LLMRateLimitError, + LLMTimeoutError, + LLMSubprocessError, +) +from llm_connect.embedding_adapter import EmbeddingAdapter +from llm_connect.embedding_openai import OpenAICompatibleEmbeddingAdapter +from llm_connect.embedding_cache import EmbeddingCache +from llm_connect.embedding_factory import create_embedding_adapter +from llm_connect.similarity import ( + cosine_similarity, + similarity_matrix, + find_similar_pairs, +) + +__all__ = [ + "RunConfig", + "LLMResponse", + "LLMAdapter", + "MockLLMAdapter", + "ErrorLLMAdapter", + "create_adapter", + "OpenRouterAdapter", + "ClaudeCodeAdapter", + "GeminiAdapter", + "OpenAIAdapter", + "LLMConfig", + "load_config", + "LLMError", + "LLMConfigurationError", + "LLMAPIError", + "LLMRateLimitError", + "LLMTimeoutError", + "LLMSubprocessError", + "EmbeddingAdapter", + "OpenAICompatibleEmbeddingAdapter", + "EmbeddingCache", + "create_embedding_adapter", + "cosine_similarity", + "similarity_matrix", + "find_similar_pairs", +] diff --git a/llm_connect/__pycache__/__init__.cpython-312.pyc b/llm_connect/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0fae8602d3dfc572c21d83f406d96654baf93ff GIT binary patch literal 1891 zcmaJ>&2Jk;6rWu?cC!9K>#dg9HjH?~7y75(z&&GVbz`@J{wQ>kPi_`dn*d++Bw zLchsH`srii&F^4*hg?LEtGPPUxK4DQAsJ1sGc3#J$Q;j+9M6-y@@3gPUmy$0o?`|t zkb<&vtjJ5Gr0hH^^9rdbd!AK!jntIA!0LRFEb=9?q}B#o<||}{uaZ^1M%MT`S?3#M zgKv^ewO?Rce4A`5yU5<*x5zDJml)=^$!%qqS%cppca&XW@AA9kF26_a@%!YyhI+`Y z{-BYaVa==$LJc8Mtf!|TS zxBwC!jgy&~*qA69m!AdB`P91dM2J949HthRXDbCyZ6fx#Gt!wzE0vu%OGyu=97!`$ z2hfDGQkpEV-L#HeJP$jHO}u;+GZ3oriCdPn-#thcHCP-$Lk>OeMTx5du49i=kUeqK z!SUj%-^vHXt55g|bzRTzrL44gJTo0o84iSTy&Le#{nLG>mH#+v0X4yoiKROWA$xMgyy=)Ku1MZ!kmPh1bAVPn3u31K|VdA zAfYIsB%v&!BB3gwCZR52QNoghWeF=1Rwb-SSeLLNVH4mp(~R?$&bOd<;APho5^i~L2;?7dzMyD-Fl_P?1sz< zf=Yd6HMt>y*B@^WxPGj@n0jLq>i_4ju}&vjfgjI4sqm+sV@E3L8+Ru ziL)h4=ImN?WoK>1EbB+~27ZO!zSlkrIBkC!h|oD}1C#XemUj9Awk-?Z)yT41{o&jB z4`2=kjD9HEkN}>16m|jr%xIeSS}$tZ%PpimdWD|;f}W00YlI$*(8CdG!rv?O@KVof m`XvJRyNa~;N9bUL9*xk)mwHyyUn79OiW>#}>%7>7r2hal&O)aE literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/_http.cpython-312.pyc b/llm_connect/__pycache__/_http.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..815ef91e2a8f96be74ebd4f4bace079ac456e5e7 GIT binary patch literal 3501 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 VZ$xjHH}?MG{JIaX8b=_Ge*;o&PPza9 literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/_token_estimator.cpython-312.pyc b/llm_connect/__pycache__/_token_estimator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cd9f97a97a220f39f7f307b2f54f9f7ace26f9c GIT binary patch literal 759 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 literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/embedding_adapter.cpython-312.pyc b/llm_connect/__pycache__/embedding_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cbcc7bb15694e14865535f545f4a28c3979c5897 GIT binary patch literal 1556 zcmah}OOG2x5bk+-Hw489ojSRC~Dz1`j2C1q`=-H~h;lqcI`pa5FxMA~+WrdL@ud4_~ zGT~E%Xfg@?Pd^T^@De!YPN$XNrK}7uiW!JJO{KzBZ-r|2EGVd_rW^iFJVb8e_3zzp zF7TFzQktw537xIRM(ZpdqjSt88#qX_E1ZERec{SLcCO)x@X!n72Kn?)ec>PZFVLeg z*u+>*o=aU3oZ1a%ooJ)Ev?jJCVE)d%v+cka`>&QX0r8}pj*@1os>EFuTU+Y(!bfw= zW=`bJf3tS}g#Dq$ty&g_(2l+u>)v`ro3>mJKB*+2NXlACK>eUhG++iR z8q~?^`NtO3Ybp9r~4%t)V^+tSuKa65bg*uDK`PM z1uQVtp(PoDsUM*^Vn6%0ehIFexaWecuipE{`})oaL-*O$XX3lt&kufzdM|0c@L1=n z4LaGeUI)XkzhpSsqd#2qmX#>~eiW#;aBe(4N))x;B3=SG3+mP8-Q~-^uZR<(QIm7x bIgW#A9slo3%(?z&XUEz4QvbzpvUUCi%VDUy literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/embedding_cache.cpython-312.pyc b/llm_connect/__pycache__/embedding_cache.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..758908548e79629d8e4ac378d02eb52b4191454e GIT binary patch literal 3544 zcmaJ^UuYc18K3=otJO)mS|>-cqiCcWN9#)GMp0-hj9t?>DM~5_tI+1!vRduVSu5?` zsyx2RNw8~Sf!Lr5uv@|w4q&TC@c88U30wd(c#c%S%yb2lQs{;i9C+o6IsF_MV;Wk4 zW42dzz@mk1zZ8NJJMKjiggEeV5L#d(c?sA=iEfoczi5T7ZFwF$zhHT;V}{8W-68foc)-RxITofNF=$-35DbyTONpgB6MwmR)jEATX&ZUc*H zklR7m2E3!vVsu39=bFI(gyCNO$2)`uZouK;p1=j4?f`=uP5}R47?U9_+hLg!?3Vz| zU=9yXP3@qVn+^QZ)RZwc7H?-q-s1%#9T>s`@uNZatMBJt8N#_-?l+f*_E-Z|GQZ5FsD)p`bX;mTqL0JQWC?y-(?FI{ z4WhT&ick%ie3=oK&4lLE5n+~C;4^lfj~be7Y05kNenS*c1sT#rwPPPHlvy*BNHOOj zOn^aXS-@u8Fo;rS(G3C;z})jK#}FeqAYOq&KB9mf#CZD$CujU3Ik^ZqVb7fOyy7r~ zVhNIj$!1edo@&O4HD8G|({xL2Xqwd*_If|pTwnnf3>Rm6b^^dG~ja-xepx2KN za@KX2vlqI7rR!SyrhZ+&k=}v>2woI2FKTb31t8Bn8L5;&oRsVYG}y{y?u5U908T)& z!1s^Y-6tB0HQ|24F_)#9^!r>*I%t<8Hh0y;xj}^Jks7)r%r;Bdt_L|%wGAO#+;MPC za%2X=B)k=zQFm=_h{>7c{VIZ?RA0Zq?}@`15?3;(kuumV;1hd6If0?NK+*+6n0Nuf zQ&SUuiA+u9CL%S4?JIW8NR1=kkSQz7k#v!S6oM7eG!W3XGx-oBoj@Xm*Smn-lcgB# zU@Po0)kFK-4Tdo2g9|{epnqku%leYO+`iPl5w^gCddb_1EP zcY~g|9el#vE&|xX9mubP++Pz|r-6PURfl*-z)+6-1wt388xjt465+g5onYa2B4>#7 zaPoLV;K)4#6@R(CKt3JY=mG*j9Upz9t{;8#n#|(<*68P2H@~Z$R14U~ zLWT$TUqN_oa3>W7HZ{hq(v)^M^i$!1*zBLe^HPOXhkYYVD$^|Q>U);~a~4DUh9fBnjyIqMlx#~k>Zq#^8t6 zjy%$zy!6%=+S||iltnT3>90XpMq%P_!0`Z2HnD@%-SH1+*#PZ9Wkt9nO(H|Ab~g%1 z3^jb8gaE5+2RTEEQY^z==ngV(7HZrp3^|fKQsRj1)CbD4Y&UWWvlUdc`=Mjn5w`bb zAXm^gD(XJEGJ1FH_Sgr%t7p5H$Ct+IhmS0KOWxCzoY6Ot+@gOAME?OR&kg&eX2Pb% zXtql_+JeF)1rCA;fNLJw#lNiu?>NFtELVhz@Ld!pjyWh1LRMM%D2T@p)rI(@AhGc- zstAkdlJqWG6u@Nz7l`G=RUa3qUyQ$qpzgaN=2;QsCJZeO|CAe;?7Nj72m6u2ntTwo zP%yxAAg=Ts?DO_@V*3s`N2yPv)cHv+>~|`K_Jy8tDgSgBgOz~AE2Kkf$IY?pV;il# z>#e;{TKhIdsSW?LxBqVbc79d84rqowCQ|v}yjfY(ba08k-sX7ok2 zi&f=Fc3eB8C)u3ga1X_uhxa{}Vw4f)%3u-5RrFNU6m2uBC|w^O+d{xL+fzz*McPEb zRwp_8RJp8fYSLSa`PqUr8k+SP-Yo}uH pPSdk6W%VcM0|H(Ugr}k^$XgjCq`ySnU!tS`RYwH;H3Gs7{2#v38)X0h literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/embedding_factory.cpython-312.pyc b/llm_connect/__pycache__/embedding_factory.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c60ea18b439cc9f39e25bb1db8bba33ffb0ca935 GIT binary patch literal 1981 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 literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/exceptions.cpython-312.pyc b/llm_connect/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f1bac7c7ce04d186ea6411959a0fe5e434395f5 GIT binary patch literal 3973 zcmcgu-ESMm5#K%Dk;e~NA68^V3X`jyD5fRTvh03DQJgrk6Q!|P!!e42O>j8zmhw4A zQrxXMVUh%3iPFUqaX!N{nVK|@<=Ia zff>gkCQc;0>SH`Z@eLjHlB_M@~K+w0J{Lrjt-CN(9S7No3H z2xUVBIV)2lkynWsS|g^+lzSn6Og8MH3ZP*Y4$z2)MgUd2P}M`DfX0{_=tey>4rroF zH|C)|fF9^V3&jlZ}G^a$fTKk+noNRwFAk8-IhOqOJ<&p69&8SI1s91nU^Aakv$r=A%w0AJZ&00?>w41akW%nCzeZGP-6XeK_5_cfA-Yv*odnjwcMk^ zR>`#>YCeXNf5)WPBAtG_WlC!h`Ta&*!?^Bw+N+!+?N)nVZ%9@80i+QFLa371_!nKeKwIYkP#lbv->}mvV+J&ZGmS z5f~$$1o2PYvOkZ`ZJfS)@~%<8UeB%fub*5$^6T$zj?Vo{pw&Mf9HOgn?T52|5=?84 z=vyg;C*YSGffI#G_y#6gbr_SF$ z_3DGsx%&fiwb{nd$nEdfpI;a2?C!}=4j!+aYxEwh#rXhCnUXS@R08v;XcU;PyD?oa zl+3b??S!shFB^924>)I*asY?GM~>lkqvzXf$1yYtcPjzVfT|W`F8))NT$d%3%HALjs>I6rP*jA~~2pc-on@joYxBa4QlVtjAy% zV2ktCLfI!mpEGAJT0&UG1?}vm*R;1;WkR!xONMQk8bpoR8pdiu10tO_Ol^^umMoKT z4cG%FN@q4?ei)YHM^HSC;u#dU!M>Szc=>o+JOvGbp~0ZM(vVeO3IFf%1=7ZQwtoPh z{mRGg5(33THldKPh*wIWR5BGxNwlk!K*4}g5`j|ET{_|r6Y$%Q#2@CaT)A}IW=r68 z-mvU4XIh?@3Yt;$DoEfwj7Y~~aLBc@j>E0_vctr**GEh^hEo>6uO^$;aLN_}F*BKV zl%*Ha+BYXBCqPW4(!ns!#3IyKrq7qm%CvUbSk~H*hLDB@3N|ON8B1WCF>f<}uvZJ* zc-OLSPkW>{S*K3q!mYUCCt%-KptFa4!|nQ3m8;4}w0)WQa+T|@ZWXd2Wl~aSIiLt6 z5kG%UU<*aq8<7r@P9>%b%#j z57ZYMqhmGsfjZns9;?ZJQAd2DkU5rXmt-;r+9l?XK0iSQL)RbsAIwE7If^sg=Drk|i@$c6`~RY*0dV_`z&n3yellwt zWgwARWRlqnuXVeRjK$2a;3fY8*5aj0LC`G6Bc=`!2?q$CPiwQrBHY*@fIi^y&o0L4 zn$QjVN}VkJUsDaAf{oclFf#z~7ZB|xm;mjV_EiKk`sJtxJo7rtEimD~o@a)hsL8il z_YwIsOhS%#!%T*s#`a#qcpgC%v+(Xi7(>Gy!br9VBN-4z5(oos6hIis-Gq_cpD>b0 z7)hTna8bA}c~#pDJC#Ma3HWT+x&C;(hkWeUagW`+EcbH9v;7eM#oL#ESygBJ8`l|r z7BF;QWI;4Tl+rCpqVhwP&}TQv*iLwm4%R9=1U@@yiAL*lI|M#Eu>m@M`{)jV&(1N# U<1HN8x32~|$@=i;pyNC9Z|nGZ9RL6T literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/factory.cpython-312.pyc b/llm_connect/__pycache__/factory.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b0131f6b35ea83594c95698e133378545febb39 GIT binary patch literal 2413 zcma)7-ESL35a09p<9z-~;-r**ZW3tYkT{i4C1mwOk=sHV zw{>(D5#phFL9VFOB8mu7ABxH!(EkA*9P)rUid2Mn=$lI`A@zybyIf+R4{YMy*_oZ$ zxtZV0exFFF2!?U;8~vn$(C=d7kHi-7{5BAG&_zVh93m14l%+~wE>Mx@80UPob-Jfnv2(Cnf{fd>KU!bO;*Pxli?zx8rO{leD>@)tP!osDYNj^ zI<7MFnocOghE}0j^(kC|GP0x0RJm>%TG@YCyjbWa#T>C^t53&0K2?tl=Ouf z1M8-7nlY2DjFiijLeVq~TI5-?N)2Y#;JWM#kh3niWnv}MeX>~AYJ`F!q1n7k=YjN( z;_jmhw4xh&_FZ>*4%^%hQBsKT-bI83E1e%0Fmv`m*}e?3ED`P zz6(7Jc+yX#9V<2xXljJ*@KV&1cFzKxzSf7J-H0?4f!1=U5w)XBKK{E@?8vr1Do^qkc9=v#N?=OhU?XE+uxp zG>7Nshj!pHG(SHkI9;IeSsrxU9aP$umZk$EyKU9QO%9(E@Nr8Gk5s`=N-#IE@J;io z3PaskoYh3-W_HxK@Rq};Z&b^AQRjH_{1gTR;*pu{s=^c4s|X+jcXF!B!asnT1G!h; z*yD?@1_1wWYontc(uG5uX~L81CLdSWg2xi(?#e~ZdhkE-J)FTL~lj!9lF0~bI;gj&-l&I&LZQmypSjmXiTQ+=(}<+}q9B99sysmrbNm&B}G zZby5YSAL8RfqWyCxjWEGW!4V?)lxFQrTW$y%}Z;Ima^|Td=JV5-IPyHPiGQNq);Gc zu~2Zryk3O|mx-><#6!SDFL%;iA7?!67ld0n+ zWLTfX(#`zZ@lFWnP8cP3HH&LkIuWr`Q2)NW`CG?3;(SL%slHB3P;nIQd659hb^321 C5veo) literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/gemini.cpython-312.pyc b/llm_connect/__pycache__/gemini.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..18a546fbfad83f7566f2f642db4369e546086b9d GIT binary patch literal 4705 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 F{lAV$nj`=K literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/models.cpython-312.pyc b/llm_connect/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..155e5e2dc40ff8d38f34599af373b982a2da0e13 GIT binary patch literal 3716 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 literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/openrouter.cpython-312.pyc b/llm_connect/__pycache__/openrouter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f68909f14fb25132fc4d7e9cd3fea92ae8b3e92 GIT binary patch literal 6296 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 bw&w&2n77dAP5%qW0>gCV-Tz6jwg&k>WW&ab literal 0 HcmV?d00001 diff --git a/llm_connect/__pycache__/similarity.cpython-312.pyc b/llm_connect/__pycache__/similarity.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32dd4451a61a4c15939c3ce96a6383db5b64ffd3 GIT binary patch literal 3634 zcmb_fU2Igx6`r|2yX<=X8$+NNGPD@61{OO(2)V>$!;gfMSZU#*f>`UjduQznckkMB z@5QWpU2P-Ot{Yb@G%D*>sf^kOBU&j{U#QfFy!Qpyw6d;vh|)?eZwuz3@YFN2cYhp6 z6;&PS&d$s^bLRYfbI$Ca+uNfEnswr~@sBVF5H|u`ZeT0@h_J zQF#Zf>q=v!QaKyaL)VlGiXMj5fJ1Ppo99_Y-vWGU1nF(S8={G_`bUWq?G!D1J;_8wSI1@%?Bap;U&<%D|~1ZjbzQd<`Bao7Y_{` zBtzp6m^yJVG03Ap4G1QyV`fUTg@ifE@+7BCS`aiH!s_ISb|r7!3g~z8EWU%s z?x48rONl`rU-fap=Ayu$=dT~0%IE0e**vo|Q-@76cPNv$EWkM*r|RRTa}v{q=aJ)+ z)S_3X*~r}9jPPEnh2glF2gKSVi3V3UoMmr^?qBHNMf8{UfhDDaD?=r%yyvHMwSC~3 ztpM@Eo?iTNYxe=QVQqw43U=ZaJKO4ZvMs>MfCb%=7*9QnXBnGgmk`#@7doK(dTRFo z=SDAdHMMsfSWZ^@nzf1 z`SP4G?JGR-_=-KloVdz302nvIm-W2ktANRrR{KfY{6Ygd0-yQaFGQL30f+GIWf&CE z(->;&fKdKMUQRC0{5tZWePAs*P(1lC)^+{t!r6~A;IozP2Rja|Z9A|QJ6JsZm4x+h ztSo=%mEC3ernlbNv)J0OI6!D#((z;fu^ZB2R6+AHq&e;~(I6cL*YXq^e+&2(T_hG> zK|JL+^ew!KF3<7xmJ~X^7xt*lWCsaW5qpuR^51&0oey~-p0ET><#NSb@YK#(+^3+$ zw-w~c=RkK1-@G)7;>z6hw_U^32~>%(t7D`Vzt$WFSYXE0AgxXCngr8}#`wic;}a9a zuz4kV$+jRhiqteQ(ToD@gl(FolTE8!SS^n+evu~OgH}E9h`SH+k!(`SSD9u_Qh}&= znC%9Nk1Zdkd>ORzvEk!OfMO$O^XLjxHgMTYJE@ulEDk)}UG^u?30y{ErO}T%i^+#W zM?anU!}h=J8eU0O>F_=6_iwKCBugjPx9=>SEaQbU<)MYM zEt8*|eH?~ORTV^6V<^^H9DVkr6Yc)CxMWN1$zzHiai$oxr}C!$^78yS*zy%<2F%XO zEohTkkU0_*24!A_!l&q%<4~8r0VQ}m0SF9jLSPM5fUr#n40~Y@U;Y3biWhf!@+BUF zFEvjERED#1{bVRSgMiAnI4UpkT>Pb`L-E#?->P+#V}agy$UO|GIAZ{YG}9#0n!#)` z3q|xLFdTW43^aT;5TH28ChcK?Ob(JJHiyZ3+VnK!3X;zf+jJ+%7eBh$qz4tc22;MX zAs99qmXULFtrrIhwqa#VQM`4U)m+mdgNb8Q&jFL+gHWmjMskzhuU8$ z53j{`7f-Jb@LIHWcg0=X+W!P8>XC&=373wpZ`)Z(F5{)sl{4iWzx78-XKJ_p%Ie;u z_oAO2T|G9s*7FYB`_7)SvDW$8de_!lu^X{Ub~(F}xxef6zjnRBFaD9L3_`0>q(*-{ z`oqy1BP&OWqYsq9^|r3!yTW5}Jo$?y-X5KZNBwXrrROuL6nhnP^%WDq;L1$)njKj z_`JFgZSQ-mZo}Q{UA^UVmAw^r>A?N1{i|L3sxqv8-q~B_)M^NYqNV<_3_Shi&ebSc MRrfp%14U^5AJ++y@Bjb+ literal 0 HcmV?d00001 diff --git a/llm_connect/_http.py b/llm_connect/_http.py new file mode 100644 index 0000000..d9429dc --- /dev/null +++ b/llm_connect/_http.py @@ -0,0 +1,86 @@ +""" +Thin synchronous HTTP helper built on :mod:`urllib.request`. + +Translates HTTP errors into typed :mod:`markitect.llm.exceptions`. +""" + +import json +import urllib.request +import urllib.error +from typing import Dict, Any, Optional + +from llm_connect.exceptions import ( + LLMAPIError, + LLMRateLimitError, + LLMTimeoutError, +) + + +def post_json( + url: str, + payload: Dict[str, Any], + headers: Optional[Dict[str, str]] = None, + timeout: int = 300, +) -> Dict[str, Any]: + """POST *payload* as JSON and return the parsed response body. + + Raises: + LLMRateLimitError: on HTTP 429 + LLMAPIError: on other non-2xx responses + LLMTimeoutError: on socket / read timeout + """ + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json", **(headers or {})}, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode() + try: + return json.loads(body) + except json.JSONDecodeError as exc: + preview = body[:300].replace("\n", "\\n") + raise LLMAPIError( + f"Invalid JSON response from {url}: {exc} — body preview: {preview!r}", + cause=exc, + ) from exc + except urllib.error.HTTPError as exc: + body = "" + try: + body = exc.read().decode() + except Exception: + pass + + if exc.code == 429: + raise LLMRateLimitError( + f"Rate limited (429) from {url}", + status_code=429, + response_body=body, + cause=exc, + ) from exc + + raise LLMAPIError( + f"HTTP {exc.code} from {url}", + status_code=exc.code, + response_body=body, + cause=exc, + ) from exc + except urllib.error.URLError as exc: + if "timed out" in str(exc.reason): + raise LLMTimeoutError( + f"Request to {url} timed out after {timeout}s", + cause=exc, + ) from exc + raise LLMAPIError( + f"URL error for {url}: {exc.reason}", + cause=exc, + ) from exc + except TimeoutError as exc: + raise LLMTimeoutError( + f"Request to {url} timed out after {timeout}s", + cause=exc, + ) from exc diff --git a/llm_connect/_token_estimator.py b/llm_connect/_token_estimator.py new file mode 100644 index 0000000..39fa0a7 --- /dev/null +++ b/llm_connect/_token_estimator.py @@ -0,0 +1,16 @@ +""" +Rough token estimation for backends that don't return usage data. + +Uses the ~4 characters per token heuristic common across English LLM tokenizers. +""" + + +def estimate_tokens(text: str) -> int: + """Estimate the number of tokens in *text*. + + This is intentionally coarse — it is only used by the Claude Code CLI + adapter where real token counts are unavailable. + """ + if not text: + return 0 + return max(1, len(text) // 4) diff --git a/llm_connect/adapter.py b/llm_connect/adapter.py new file mode 100644 index 0000000..e8d4574 --- /dev/null +++ b/llm_connect/adapter.py @@ -0,0 +1,169 @@ +""" +LLM adapter interface for pluggable model providers. + +Implements abstraction layer for LLM integration, supporting +multiple providers (OpenAI, Anthropic, local models, etc.). +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any + +from llm_connect.models import RunConfig, LLMResponse + + +class LLMAdapter(ABC): + """ + Abstract base class for LLM providers. + + Enables pluggable LLM backends without prescribing implementation. + Implementations can wrap OpenAI, Anthropic, or other APIs. + """ + + @abstractmethod + def execute_prompt( + self, + prompt: str, + config: RunConfig, + ) -> LLMResponse: + """ + Execute a prompt with the LLM. + + Args: + prompt: Compiled prompt text + config: Execution configuration + + Returns: + LLMResponse with generated content + + Raises: + Exception: On LLM API errors + """ + pass + + @abstractmethod + def validate_config(self, config: RunConfig) -> bool: + """ + Validate that configuration is supported. + + Args: + config: Configuration to validate + + Returns: + True if valid, False otherwise + """ + pass + + +class MockLLMAdapter(LLMAdapter): + """ + Mock LLM adapter for testing. + + Returns deterministic responses without calling external APIs. + """ + + def __init__(self, mock_response: str = "Mock LLM response"): + """ + Initialize mock adapter. + + Args: + mock_response: Response to return + """ + self.mock_response = mock_response + self.call_count = 0 + self.last_prompt = None + self.last_config = None + + def execute_prompt( + self, + prompt: str, + config: RunConfig, + ) -> LLMResponse: + """ + Return mock response. + + Args: + prompt: Prompt (stored for inspection) + config: Config (stored for inspection) + + Returns: + Mock LLMResponse + """ + self.call_count += 1 + self.last_prompt = prompt + self.last_config = config + + return LLMResponse( + content=self.mock_response, + model=config.model_name, + usage={ + "prompt_tokens": len(prompt.split()), + "completion_tokens": len(self.mock_response.split()), + "total_tokens": len(prompt.split()) + len(self.mock_response.split()), + }, + finish_reason="stop", + metadata={"mock": True}, + ) + + def validate_config(self, config: RunConfig) -> bool: + """ + Mock validation always succeeds. + + Args: + config: Configuration + + Returns: + Always True + """ + return True + + def reset(self) -> None: + """Reset mock state.""" + self.call_count = 0 + self.last_prompt = None + self.last_config = None + + +class ErrorLLMAdapter(LLMAdapter): + """ + Mock adapter that always raises an error. + + Useful for testing error handling. + """ + + def __init__(self, error_message: str = "Mock LLM error"): + """ + Initialize error adapter. + + Args: + error_message: Error message to raise + """ + self.error_message = error_message + + def execute_prompt( + self, + prompt: str, + config: RunConfig, + ) -> LLMResponse: + """ + Raise error. + + Args: + prompt: Prompt + config: Config + + Raises: + RuntimeError: Always + """ + raise RuntimeError(self.error_message) + + def validate_config(self, config: RunConfig) -> bool: + """ + Validation succeeds. + + Args: + config: Configuration + + Returns: + True + """ + return True diff --git a/llm_connect/claude_code.py b/llm_connect/claude_code.py new file mode 100644 index 0000000..534c80a --- /dev/null +++ b/llm_connect/claude_code.py @@ -0,0 +1,94 @@ +""" +Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess. +""" + +import subprocess +from typing import Optional + +from llm_connect.adapter import LLMAdapter +from llm_connect.models import RunConfig, LLMResponse +from llm_connect.config import LLMConfig +from llm_connect._token_estimator import estimate_tokens +from llm_connect.exceptions import ( + LLMSubprocessError, + LLMTimeoutError, +) + + +class ClaudeCodeAdapter(LLMAdapter): + """LLM adapter that shells out to the ``claude`` CLI with ``--print``. + + The compiled prompt is piped via **stdin** to avoid shell argument + length limits (compiled prompts can exceed 30 KB). + """ + + def __init__( + self, + cli_path: str = "claude", + model: Optional[str] = None, + config: Optional[LLMConfig] = None, + ): + self._config = config or LLMConfig(provider="claude-code") + self._cli_path = cli_path or self._config.claude_cli_path + self._model = model + + # ── LLMAdapter interface ──────────────────────────────────────── + + def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: + cmd = [self._cli_path, "--print"] + if self._model: + cmd.extend(["--model", self._model]) + + timeout = config.timeout_seconds or self._config.timeout_seconds + + try: + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise LLMTimeoutError( + f"claude CLI timed out after {timeout}s", + cause=exc, + ) from exc + + if result.returncode != 0: + raise LLMSubprocessError( + f"claude CLI exited with code {result.returncode}", + return_code=result.returncode, + stderr=result.stderr, + ) + + content = result.stdout + prompt_tokens = estimate_tokens(prompt) + completion_tokens = estimate_tokens(content) + + return LLMResponse( + content=content, + model=self._model or "claude-code-cli", + usage={ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + finish_reason="stop", + metadata={ + "provider": "claude-code", + "cli_path": self._cli_path, + }, + ) + + def validate_config(self, config: RunConfig) -> bool: + try: + result = subprocess.run( + [self._cli_path, "--version"], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return False diff --git a/llm_connect/config.py b/llm_connect/config.py new file mode 100644 index 0000000..42df2b3 --- /dev/null +++ b/llm_connect/config.py @@ -0,0 +1,108 @@ +""" +LLM configuration and API key resolution. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Dict, Any +import os + + +@dataclass +class LLMConfig: + """Configuration for an LLM adapter. + + Attributes: + provider: Backend identifier (``"openrouter"`` or ``"claude-code"``). + model: Model name / path sent to the provider. + api_key: Resolved API key (may be ``None`` for CLI backends). + api_base: Base URL for HTTP-based providers. + claude_cli_path: Path to the ``claude`` CLI binary. + timeout_seconds: Per-request timeout. + max_retries: Number of retry attempts on transient errors. + extra: Arbitrary provider-specific overrides. + """ + + provider: str = "openrouter" + model: str = "anthropic/claude-sonnet-4" + api_key: Optional[str] = None + api_base: str = "https://openrouter.ai/api/v1" + claude_cli_path: str = "claude" + timeout_seconds: int = 300 + max_retries: int = 3 + extra: Dict[str, Any] = field(default_factory=dict) + + +def resolve_api_key( + explicit: Optional[str] = None, + env_var: str = "OPENROUTER_API_KEY", + key_file_paths: Optional[list[Path]] = None, +) -> Optional[str]: + """Return an API key from the first available source. + + Resolution order: + 1. *explicit* argument (passed directly by caller) + 2. Environment variable *env_var* + 3. First readable file in *key_file_paths* whose content is non-empty + + Returns ``None`` if no key can be found. + """ + if explicit: + return explicit + + from_env = os.environ.get(env_var) + if from_env: + return from_env.strip() + + for path in key_file_paths or []: + try: + text = path.read_text().strip() + if text: + return text + except OSError: + continue + + return None + + +def find_project_root(start: Optional[Path] = None) -> Optional[Path]: + """Walk up from *start* (default CWD) looking for ``pyproject.toml``. + + Returns the directory containing the marker file, or ``None``. + """ + current = (start or Path.cwd()).resolve() + for directory in [current, *current.parents]: + if (directory / "pyproject.toml").is_file(): + return directory + return None + + +def load_config( + provider: str = "openrouter", + model: Optional[str] = None, + api_key: Optional[str] = None, + **overrides: Any, +) -> LLMConfig: + """Build an :class:`LLMConfig` with sensible defaults. + + For the ``openrouter`` provider the API key is resolved via + :func:`resolve_api_key` (env var → project-root key file). + """ + root = find_project_root() + key_file_paths = [root / "apikey-openrouter.txt"] if root else [] + + resolved_key = api_key + if provider == "openrouter" and not resolved_key: + resolved_key = resolve_api_key( + explicit=None, + env_var="OPENROUTER_API_KEY", + key_file_paths=key_file_paths, + ) + + defaults: Dict[str, Any] = { + "provider": provider, + "model": model or "anthropic/claude-sonnet-4", + "api_key": resolved_key, + } + defaults.update(overrides) + return LLMConfig(**defaults) diff --git a/llm_connect/embedding_adapter.py b/llm_connect/embedding_adapter.py new file mode 100644 index 0000000..d4b75e1 --- /dev/null +++ b/llm_connect/embedding_adapter.py @@ -0,0 +1,34 @@ +""" +Abstract base class for embedding adapters. + +Embedding adapters convert text into float vectors. This is a separate +hierarchy from :class:`LLMAdapter` (text generation) because the API +contract is fundamentally different: text in, float vectors out. +""" + +from abc import ABC, abstractmethod + + +class EmbeddingAdapter(ABC): + """Base class for all embedding adapters.""" + + @abstractmethod + def embed(self, texts: list[str]) -> list[list[float]]: + """Embed a batch of texts into vectors. + + Args: + texts: One or more strings to embed. + + Returns: + A list of embedding vectors, one per input text, + in the same order as *texts*. + """ + + @abstractmethod + def validate(self) -> bool: + """Check that the adapter is configured correctly. + + Returns: + ``True`` if the adapter has a valid configuration + (e.g. API key present), ``False`` otherwise. + """ diff --git a/llm_connect/embedding_cache.py b/llm_connect/embedding_cache.py new file mode 100644 index 0000000..5273b86 --- /dev/null +++ b/llm_connect/embedding_cache.py @@ -0,0 +1,64 @@ +""" +File-based embedding cache. + +Stores embedding vectors in a single JSON file keyed by entity slug. +Each entry includes a content digest so stale embeddings are +automatically invalidated when entity content changes. +""" + +import json +from pathlib import Path +from typing import Optional + + +class EmbeddingCache: + """Persistent cache for embedding vectors. + + Structure on disk (``embeddings.json``):: + + { + "division-of-labour": {"digest": "abc123", "vector": [0.1, ...]}, + ... + } + """ + + def __init__(self, cache_dir: Path): + self._path = cache_dir / "embeddings.json" + self._data: dict[str, dict] = {} + self._hits = 0 + self._misses = 0 + self._load() + + def get(self, slug: str, content_digest: str) -> Optional[list[float]]: + """Return the cached vector if *content_digest* matches, else ``None``.""" + entry = self._data.get(slug) + if entry is not None and entry.get("digest") == content_digest: + self._hits += 1 + return entry["vector"] + self._misses += 1 + return None + + def put(self, slug: str, content_digest: str, vector: list[float]) -> None: + """Store or overwrite the embedding for *slug*.""" + self._data[slug] = {"digest": content_digest, "vector": vector} + + def save(self) -> None: + """Write cache to disk.""" + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._data, separators=(",", ":"))) + + def stats(self) -> dict: + """Return cache statistics.""" + return { + "entries": len(self._data), + "hits": self._hits, + "misses": self._misses, + } + + def _load(self) -> None: + """Read cache from disk if it exists.""" + if self._path.is_file(): + try: + self._data = json.loads(self._path.read_text()) + except (json.JSONDecodeError, OSError): + self._data = {} diff --git a/llm_connect/embedding_factory.py b/llm_connect/embedding_factory.py new file mode 100644 index 0000000..f81f07b --- /dev/null +++ b/llm_connect/embedding_factory.py @@ -0,0 +1,50 @@ +""" +Factory for creating embedding adapters by provider name. +""" + +from typing import Optional, Any + +from llm_connect.embedding_adapter import EmbeddingAdapter +from llm_connect.exceptions import LLMConfigurationError + +_EMBEDDING_PROVIDERS = { + "openai": "llm_connect.embedding_openai.OpenAICompatibleEmbeddingAdapter", + "openrouter": "llm_connect.embedding_openai.OpenAICompatibleEmbeddingAdapter", +} + + +def create_embedding_adapter( + provider: str = "openai", + model: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs: Any, +) -> EmbeddingAdapter: + """Instantiate an :class:`EmbeddingAdapter` for the given *provider*. + + Args: + provider: ``"openai"`` or ``"openrouter"``. + model: Embedding model name (e.g. ``"text-embedding-3-small"``). + api_key: Explicit API key. + **kwargs: Extra keyword arguments forwarded to the adapter. + + Returns: + A ready-to-use :class:`EmbeddingAdapter` instance. + + Raises: + LLMConfigurationError: If *provider* is not recognised. + """ + if provider not in _EMBEDDING_PROVIDERS: + known = ", ".join(sorted(_EMBEDDING_PROVIDERS)) + raise LLMConfigurationError( + f"Unknown embedding provider {provider!r}. Choose from: {known}", + context={"provider": provider}, + ) + + # Lazy import + fqn = _EMBEDDING_PROVIDERS[provider] + module_path, class_name = fqn.rsplit(".", 1) + import importlib + mod = importlib.import_module(module_path) + cls = getattr(mod, class_name) + + return cls(model=model, api_key=api_key, provider=provider, **kwargs) diff --git a/llm_connect/embedding_openai.py b/llm_connect/embedding_openai.py new file mode 100644 index 0000000..06d646b --- /dev/null +++ b/llm_connect/embedding_openai.py @@ -0,0 +1,125 @@ +""" +OpenAI-compatible embedding adapter. + +Works with both OpenAI (``/v1/embeddings``) and OpenRouter +(``/api/v1/embeddings``) since they share the same API format. +The *provider* parameter determines the default base URL and +API key environment variable. +""" + +import time +from typing import Optional, Dict, Any + +from llm_connect.embedding_adapter import EmbeddingAdapter +from llm_connect.config import resolve_api_key, find_project_root +from llm_connect._http import post_json +from llm_connect.exceptions import ( + LLMConfigurationError, + LLMAPIError, + LLMRateLimitError, +) + +_DEFAULT_MODEL = "text-embedding-3-small" + +_PROVIDER_DEFAULTS: Dict[str, Dict[str, str]] = { + "openai": { + "api_base": "https://api.openai.com/v1", + "env_var": "OPENAI_API_KEY", + }, + "openrouter": { + "api_base": "https://openrouter.ai/api/v1", + "env_var": "OPENROUTER_API_KEY", + }, +} + + +class OpenAICompatibleEmbeddingAdapter(EmbeddingAdapter): + """Embedding adapter for OpenAI-compatible endpoints. + + A single class handles both OpenAI and OpenRouter because they + expose the same ``/embeddings`` endpoint format. + """ + + def __init__( + self, + model: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + provider: str = "openai", + max_retries: int = 3, + ): + if provider not in _PROVIDER_DEFAULTS: + known = ", ".join(sorted(_PROVIDER_DEFAULTS)) + raise LLMConfigurationError( + f"Unknown embedding provider {provider!r}. Choose from: {known}", + context={"provider": provider}, + ) + + defaults = _PROVIDER_DEFAULTS[provider] + self._model = model or _DEFAULT_MODEL + self._api_base = (api_base or defaults["api_base"]).rstrip("/") + self._max_retries = max_retries + self._provider = provider + + # Resolve API key + env_var = defaults["env_var"] + root = find_project_root() + key_file_paths = [root / f"apikey-{provider}.txt"] if root else [] + self._api_key = resolve_api_key( + explicit=api_key, + env_var=env_var, + key_file_paths=key_file_paths, + ) + + def embed(self, texts: list[str]) -> list[list[float]]: + """Embed texts via the OpenAI-compatible ``/embeddings`` endpoint. + + Raises: + LLMConfigurationError: If no API key is configured. + LLMAPIError: On HTTP errors after retries are exhausted. + """ + if not self._api_key: + raise LLMConfigurationError( + "No API key configured for embedding adapter", + context={"provider": self._provider}, + ) + + url = f"{self._api_base}/embeddings" + payload: Dict[str, Any] = { + "model": self._model, + "input": texts, + } + headers = {"Authorization": f"Bearer {self._api_key}"} + + data = self._post_with_retries(url, payload, headers) + + # Response: {"data": [{"embedding": [...], "index": 0}, ...]} + # Sort by index to guarantee input order. + items = sorted(data["data"], key=lambda d: d["index"]) + return [item["embedding"] for item in items] + + def validate(self) -> bool: + """Return ``True`` if an API key is available.""" + return self._api_key is not None + + def _post_with_retries( + self, + url: str, + payload: Dict[str, Any], + headers: Dict[str, str], + ) -> Dict[str, Any]: + last_exc: Optional[Exception] = None + for attempt in range(self._max_retries + 1): + try: + return post_json(url, payload, headers) + except LLMRateLimitError as exc: + last_exc = exc + if attempt < self._max_retries: + time.sleep(2 ** attempt) + except LLMAPIError as exc: + if exc.status_code >= 500 and attempt < self._max_retries: + last_exc = exc + time.sleep(2 ** attempt) + else: + raise + raise last_exc # type: ignore[misc] diff --git a/llm_connect/exceptions.py b/llm_connect/exceptions.py new file mode 100644 index 0000000..f2fc34d --- /dev/null +++ b/llm_connect/exceptions.py @@ -0,0 +1,85 @@ +""" +LLM-specific exceptions. +""" + +from typing import Optional, Dict, Any + + +class LLMError(Exception): + """Base exception for all LLM operations.""" + + def __init__( + self, + message: str, + cause: Optional[Exception] = None, + context: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.cause = cause + self.context = context or {} + if cause: + self.__cause__ = cause + + def __str__(self) -> str: + base = super().__str__() + if self.context: + ctx = ", ".join(f"{k}={v}" for k, v in self.context.items()) + base = f"{base} [Context: {ctx}]" + return base + + +class LLMConfigurationError(LLMError): + """Missing API key, invalid model name, or bad provider config.""" + pass + + +class LLMAPIError(LLMError): + """HTTP-level failure from an LLM provider API. + + Attributes: + status_code: HTTP status code (e.g. 500, 502). + response_body: Raw response body text, if available. + """ + + def __init__( + self, + message: str, + status_code: int = 0, + response_body: str = "", + cause: Optional[Exception] = None, + context: Optional[Dict[str, Any]] = None, + ): + super().__init__(message, cause=cause, context=context) + self.status_code = status_code + self.response_body = response_body + + +class LLMRateLimitError(LLMAPIError): + """429 Too Many Requests from the provider.""" + pass + + +class LLMTimeoutError(LLMError): + """Request or subprocess exceeded the configured timeout.""" + pass + + +class LLMSubprocessError(LLMError): + """Claude Code CLI subprocess failed. + + Attributes: + return_code: Process exit code. + stderr: Captured stderr text. + """ + + def __init__( + self, + message: str, + return_code: int = 1, + stderr: str = "", + cause: Optional[Exception] = None, + context: Optional[Dict[str, Any]] = None, + ): + super().__init__(message, cause=cause, context=context) + self.return_code = return_code + self.stderr = stderr diff --git a/llm_connect/factory.py b/llm_connect/factory.py new file mode 100644 index 0000000..cca9ae9 --- /dev/null +++ b/llm_connect/factory.py @@ -0,0 +1,60 @@ +""" +Factory for creating LLM adapters by provider name. +""" + +from typing import Optional, Dict, Any + +from llm_connect.adapter import LLMAdapter +from llm_connect.exceptions import LLMConfigurationError + +# Lazy imports to avoid pulling in every adapter at module load time. +_PROVIDERS: Dict[str, str] = { + "openrouter": "llm_connect.openrouter.OpenRouterAdapter", + "claude-code": "llm_connect.claude_code.ClaudeCodeAdapter", + "gemini": "llm_connect.gemini.GeminiAdapter", + "openai": "llm_connect.openai.OpenAIAdapter", +} + + +def create_adapter( + provider: str = "openrouter", + model: Optional[str] = None, + api_key: Optional[str] = None, + system_prompt: Optional[str] = None, + **kwargs: Any, +) -> LLMAdapter: + """Instantiate an :class:`LLMAdapter` for the given *provider*. + + Args: + provider: ``"openrouter"``, ``"claude-code"``, ``"gemini"``, or ``"openai"``. + model: Model name (passed to the adapter constructor). + api_key: Explicit API key (OpenRouter / Gemini / OpenAI). + system_prompt: Optional system prompt (OpenRouter / Gemini / OpenAI). + **kwargs: Extra keyword arguments forwarded to the adapter. + + Returns: + A ready-to-use :class:`LLMAdapter` instance. + + Raises: + LLMConfigurationError: If *provider* is not recognised. + """ + if provider not in _PROVIDERS: + known = ", ".join(sorted(_PROVIDERS)) + raise LLMConfigurationError( + f"Unknown LLM provider {provider!r}. Choose from: {known}", + context={"provider": provider}, + ) + + # Lazy import + fqn = _PROVIDERS[provider] + module_path, class_name = fqn.rsplit(".", 1) + import importlib + mod = importlib.import_module(module_path) + cls = getattr(mod, class_name) + + if provider in ("openrouter", "gemini", "openai"): + return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs) + elif provider == "claude-code": + return cls(model=model, **kwargs) + else: + return cls(**kwargs) # pragma: no cover diff --git a/llm_connect/gemini.py b/llm_connect/gemini.py new file mode 100644 index 0000000..667f952 --- /dev/null +++ b/llm_connect/gemini.py @@ -0,0 +1,115 @@ +""" +Google Gemini adapter — calls the Generative Language REST API directly. +""" + +import time +from typing import Optional, Dict, Any + +from llm_connect.adapter import LLMAdapter +from llm_connect.models import RunConfig, LLMResponse +from llm_connect.config import resolve_api_key, find_project_root +from llm_connect._http import post_json +from llm_connect.exceptions import LLMConfigurationError + +_DEFAULT_MODEL = "gemini-2.5-flash" +_API_BASE = "https://generativelanguage.googleapis.com/v1beta" + + +class GeminiAdapter(LLMAdapter): + """LLM adapter that calls the Google Generative Language API. + + Supports the free tier of Gemini models via a Google AI Studio API key. + """ + + def __init__( + self, + model: Optional[str] = None, + api_key: Optional[str] = None, + system_prompt: Optional[str] = None, + **_kwargs: Any, + ): + self._model = model or _DEFAULT_MODEL + self._system_prompt = system_prompt + + root = find_project_root() + key_file_paths = [root / "apikey-geminifree.txt"] if root else [] + self._api_key = resolve_api_key( + explicit=api_key, + env_var="GEMINI_API_KEY", + key_file_paths=key_file_paths, + ) + if not self._api_key: + raise LLMConfigurationError( + "No Gemini API key found. Set GEMINI_API_KEY or create " + "apikey-geminifree.txt in the project root.", + context={"provider": "gemini"}, + ) + + # ── LLMAdapter interface ──────────────────────────────────────── + + def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: + model = self._model + + # Build Gemini request + contents: list[Dict[str, Any]] = [] + if self._system_prompt: + contents.append({ + "role": "user", + "parts": [{"text": self._system_prompt}], + }) + contents.append({ + "role": "model", + "parts": [{"text": "Understood."}], + }) + contents.append({ + "role": "user", + "parts": [{"text": prompt}], + }) + + payload: Dict[str, Any] = { + "contents": contents, + "generationConfig": { + "temperature": config.temperature, + "maxOutputTokens": config.max_tokens, + }, + } + + url = f"{_API_BASE}/models/{model}:generateContent?key={self._api_key}" + + start = time.time() + data = post_json(url, payload, timeout=config.timeout_seconds) + latency = time.time() - start + + # Parse Gemini response + candidates = data.get("candidates", []) + if not candidates: + content = "" + finish_reason = "error" + else: + parts = candidates[0].get("content", {}).get("parts", []) + content = "".join(p.get("text", "") for p in parts) + finish_reason = candidates[0].get("finishReason", "STOP").lower() + + usage_meta = data.get("usageMetadata", {}) + + return LLMResponse( + content=content, + model=model, + usage={ + "prompt_tokens": usage_meta.get("promptTokenCount", 0), + "completion_tokens": usage_meta.get("candidatesTokenCount", 0), + "total_tokens": usage_meta.get("totalTokenCount", 0), + }, + finish_reason=finish_reason, + metadata={ + "provider": "gemini", + "latency_seconds": round(latency, 3), + }, + ) + + def validate_config(self, config: RunConfig) -> bool: + if not self._api_key: + return False + if not (0.0 <= config.temperature <= 2.0): + return False + return True diff --git a/llm_connect/models.py b/llm_connect/models.py new file mode 100644 index 0000000..5872918 --- /dev/null +++ b/llm_connect/models.py @@ -0,0 +1,86 @@ +""" +Shared data models for LLM execution. + +These classes are the canonical definitions; they are re-exported by +markitect.prompts.execution.models for backward compatibility. +""" + +from dataclasses import dataclass, field +from typing import Dict, Any + + +@dataclass +class RunConfig: + """ + Configuration for prompt execution. + + Attributes: + model_name: LLM model to use + temperature: Model temperature (0.0-1.0) + max_tokens: Maximum tokens to generate + model_params: Additional model parameters + max_depth: Maximum generation depth for nested runs + skip_if_exists: Skip if identical InputBundleHash exists + timeout_seconds: Execution timeout + """ + model_name: str = "gpt-4" + temperature: float = 0.7 + max_tokens: int = 2000 + model_params: Dict[str, Any] = field(default_factory=dict) + max_depth: int = 3 + skip_if_exists: bool = True + timeout_seconds: int = 300 + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "model_name": self.model_name, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "model_params": self.model_params, + "max_depth": self.max_depth, + "skip_if_exists": self.skip_if_exists, + "timeout_seconds": self.timeout_seconds, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "RunConfig": + """Create from dictionary.""" + return cls( + model_name=data.get("model_name", "gpt-4"), + temperature=data.get("temperature", 0.7), + max_tokens=data.get("max_tokens", 2000), + model_params=data.get("model_params", {}), + max_depth=data.get("max_depth", 3), + skip_if_exists=data.get("skip_if_exists", True), + timeout_seconds=data.get("timeout_seconds", 300), + ) + + +@dataclass +class LLMResponse: + """ + Response from LLM execution. + + Attributes: + content: Generated content + model: Model used + usage: Token usage statistics + finish_reason: Why generation stopped + metadata: Additional response metadata + """ + content: str + model: str + usage: Dict[str, int] = field(default_factory=dict) + finish_reason: str = "stop" + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "content": self.content, + "model": self.model, + "usage": self.usage, + "finish_reason": self.finish_reason, + "metadata": self.metadata, + } diff --git a/llm_connect/openai.py b/llm_connect/openai.py new file mode 100644 index 0000000..285aa60 --- /dev/null +++ b/llm_connect/openai.py @@ -0,0 +1,129 @@ +""" +OpenAI (ChatGPT) adapter — calls the OpenAI chat completions API. +""" + +import time +from typing import Optional, Dict, Any + +from llm_connect.adapter import LLMAdapter +from llm_connect.models import RunConfig, LLMResponse +from llm_connect.config import resolve_api_key, find_project_root +from llm_connect._http import post_json +from llm_connect.exceptions import ( + LLMConfigurationError, + LLMAPIError, + LLMRateLimitError, +) + +_DEFAULT_MODEL = "gpt-4.1-mini" +_API_BASE = "https://api.openai.com/v1" + + +class OpenAIAdapter(LLMAdapter): + """LLM adapter that calls the OpenAI chat completions endpoint.""" + + def __init__( + self, + model: Optional[str] = None, + api_key: Optional[str] = None, + system_prompt: Optional[str] = None, + max_retries: int = 3, + **_kwargs: Any, + ): + self._model = model or _DEFAULT_MODEL + self._system_prompt = system_prompt + self._max_retries = max_retries + + root = find_project_root() + key_file_paths = [root / "apikey-chatgpt.txt"] if root else [] + self._api_key = resolve_api_key( + explicit=api_key, + env_var="OPENAI_API_KEY", + key_file_paths=key_file_paths, + ) + if not self._api_key: + raise LLMConfigurationError( + "No OpenAI API key found. Set OPENAI_API_KEY or create " + "apikey-chatgpt.txt in the project root.", + context={"provider": "openai"}, + ) + + # ── LLMAdapter interface ──────────────────────────────────────── + + def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: + model = self._model + + messages: list[Dict[str, str]] = [] + if self._system_prompt: + messages.append({"role": "system", "content": self._system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + } + + headers = { + "Authorization": f"Bearer {self._api_key}", + } + url = f"{_API_BASE}/chat/completions" + + start = time.time() + data = self._post_with_retries(url, payload, headers, config.timeout_seconds) + latency = time.time() - start + + # Parse response (OpenAI chat completions format) + choice = data.get("choices", [{}])[0] + content = choice.get("message", {}).get("content", "") + finish_reason = choice.get("finish_reason", "stop") + usage = data.get("usage", {}) + + return LLMResponse( + content=content, + model=data.get("model", model), + usage={ + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + finish_reason=finish_reason, + metadata={ + "provider": "openai", + "latency_seconds": round(latency, 3), + "response_id": data.get("id", ""), + }, + ) + + def validate_config(self, config: RunConfig) -> bool: + if not self._api_key: + return False + if not (0.0 <= config.temperature <= 2.0): + return False + return True + + # ── Internals ─────────────────────────────────────────────────── + + def _post_with_retries( + self, + url: str, + payload: Dict[str, Any], + headers: Dict[str, str], + timeout: int, + ) -> Dict[str, Any]: + last_exc: Optional[Exception] = None + for attempt in range(self._max_retries + 1): + try: + return post_json(url, payload, headers, timeout=timeout) + except LLMRateLimitError as exc: + last_exc = exc + if attempt < self._max_retries: + time.sleep(2 ** attempt) + except LLMAPIError as exc: + if exc.status_code >= 500 and attempt < self._max_retries: + last_exc = exc + time.sleep(2 ** attempt) + else: + raise + raise last_exc # type: ignore[misc] diff --git a/llm_connect/openrouter.py b/llm_connect/openrouter.py new file mode 100644 index 0000000..97c9aa9 --- /dev/null +++ b/llm_connect/openrouter.py @@ -0,0 +1,139 @@ +""" +OpenRouter adapter — calls the OpenAI-compatible chat completions API. +""" + +import time +from typing import Optional, Dict, Any + +from llm_connect.adapter import LLMAdapter +from llm_connect.models import RunConfig, LLMResponse +from llm_connect.config import LLMConfig, resolve_api_key, find_project_root +from llm_connect._http import post_json +from llm_connect.exceptions import ( + LLMConfigurationError, + LLMAPIError, + LLMRateLimitError, +) + +_DEFAULT_MODEL = "anthropic/claude-sonnet-4" + + +class OpenRouterAdapter(LLMAdapter): + """LLM adapter that calls the OpenRouter chat completions endpoint. + + Constructor args override values from *config*; *config* overrides + global defaults. The model used for a given call is resolved as: + ``constructor model > RunConfig.model_name > default``. + """ + + def __init__( + self, + model: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + config: Optional[LLMConfig] = None, + system_prompt: Optional[str] = None, + extra_headers: Optional[Dict[str, str]] = None, + max_retries: Optional[int] = None, + ): + self._config = config or LLMConfig() + self._model = model or self._config.model or _DEFAULT_MODEL + self._api_base = (api_base or self._config.api_base).rstrip("/") + self._system_prompt = system_prompt + self._extra_headers = extra_headers or {} + self._max_retries = max_retries if max_retries is not None else self._config.max_retries + + # Resolve API key + root = find_project_root() + key_file_paths = [root / "apikey-openrouter.txt"] if root else [] + self._api_key = resolve_api_key( + explicit=api_key or self._config.api_key, + env_var="OPENROUTER_API_KEY", + key_file_paths=key_file_paths, + ) + + # ── LLMAdapter interface ──────────────────────────────────────── + + def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: + model = self._model if self._model != _DEFAULT_MODEL else (config.model_name or self._model) + + messages: list[Dict[str, str]] = [] + if self._system_prompt: + messages.append({"role": "system", "content": self._system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + } + # Merge extra model_params from RunConfig + if config.model_params: + payload.update(config.model_params) + + headers = { + "Authorization": f"Bearer {self._api_key}", + **self._extra_headers, + } + url = f"{self._api_base}/chat/completions" + + start = time.time() + data = self._post_with_retries(url, payload, headers, config.timeout_seconds) + latency = time.time() - start + + # Parse response + choice = data.get("choices", [{}])[0] + content = choice.get("message", {}).get("content", "") + finish_reason = choice.get("finish_reason", "stop") + usage = data.get("usage", {}) + + return LLMResponse( + content=content, + model=data.get("model", model), + usage={ + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + finish_reason=finish_reason, + metadata={ + "provider": "openrouter", + "latency_seconds": round(latency, 3), + "response_id": data.get("id", ""), + }, + ) + + def validate_config(self, config: RunConfig) -> bool: + if not self._api_key: + return False + if not (self._model or config.model_name): + return False + if not (0.0 <= config.temperature <= 2.0): + return False + return True + + # ── Internals ─────────────────────────────────────────────────── + + def _post_with_retries( + self, + url: str, + payload: Dict[str, Any], + headers: Dict[str, str], + timeout: int, + ) -> Dict[str, Any]: + last_exc: Optional[Exception] = None + for attempt in range(self._max_retries + 1): + try: + return post_json(url, payload, headers, timeout=timeout) + except LLMRateLimitError as exc: + last_exc = exc + if attempt < self._max_retries: + time.sleep(2 ** attempt) + except LLMAPIError as exc: + if exc.status_code >= 500 and attempt < self._max_retries: + last_exc = exc + time.sleep(2 ** attempt) + else: + raise + raise last_exc # type: ignore[misc] diff --git a/llm_connect/similarity.py b/llm_connect/similarity.py new file mode 100644 index 0000000..eb8901d --- /dev/null +++ b/llm_connect/similarity.py @@ -0,0 +1,64 @@ +""" +Pure-Python vector similarity utilities. + +No external dependencies — uses :mod:`math` only. Sufficient for the +current entity scale (~100s). numpy can be substituted later if needed. +""" + +import math + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + """Cosine similarity between two vectors. + + Returns a float in [-1, 1]. Returns 0.0 if either vector has + zero magnitude (to avoid division by zero). + """ + dot = sum(x * y for x, y in zip(a, b)) + mag_a = math.sqrt(sum(x * x for x in a)) + mag_b = math.sqrt(sum(x * x for x in b)) + if mag_a == 0.0 or mag_b == 0.0: + return 0.0 + return dot / (mag_a * mag_b) + + +def similarity_matrix(embeddings: list[list[float]]) -> list[list[float]]: + """Build an NxN cosine similarity matrix. + + ``matrix[i][j]`` is the cosine similarity between + ``embeddings[i]`` and ``embeddings[j]``. + """ + n = len(embeddings) + mat: list[list[float]] = [[0.0] * n for _ in range(n)] + for i in range(n): + mat[i][i] = 1.0 + for j in range(i + 1, n): + sim = cosine_similarity(embeddings[i], embeddings[j]) + mat[i][j] = sim + mat[j][i] = sim + return mat + + +def find_similar_pairs( + embeddings: dict[str, list[float]], + threshold: float = 0.80, +) -> list[tuple[str, str, float]]: + """Find all pairs with cosine similarity >= *threshold*. + + Args: + embeddings: Mapping of slug → embedding vector. + threshold: Minimum similarity to include (default 0.80). + + Returns: + List of ``(slug_a, slug_b, similarity)`` tuples sorted by + similarity descending. + """ + slugs = sorted(embeddings) + pairs: list[tuple[str, str, float]] = [] + for i, slug_a in enumerate(slugs): + for slug_b in slugs[i + 1:]: + sim = cosine_similarity(embeddings[slug_a], embeddings[slug_b]) + if sim >= threshold: + pairs.append((slug_a, slug_b, sim)) + pairs.sort(key=lambda t: t[2], reverse=True) + return pairs diff --git a/llm_connect/toml_config.py b/llm_connect/toml_config.py new file mode 100644 index 0000000..e2d1401 --- /dev/null +++ b/llm_connect/toml_config.py @@ -0,0 +1,271 @@ +""" +TOML-based LLM configuration: defaults, preferences, and resolution. + +Config files: + - Directory: ``/.markitect.toml`` + - User: ``~/.config/markitect/config.toml`` + +Resolution order (highest → lowest): + 1. CLI flags (``--provider``, ``--model``) + 2. ``MARKITECT_HELPER_MODEL`` env var (model only) + 3. User preference (``[llm.preference]`` in user config) + 4. Directory preference (``[llm.preference]`` in directory config) + 5. Directory default (``[llm.default]`` in directory config) + 6. User default (``[llm.default]`` in user config) + 7. Hardcoded fallback +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import toml + +from llm_connect.config import find_project_root + +# ── Constants ───────────────────────────────────────────────────────────── + +HARDCODED_PROVIDER = "gemini" +HARDCODED_MODEL = "gemini-2.5-flash" + +# Default (markitect) values kept for backward compatibility. +MODEL_ENV_VAR = "MARKITECT_HELPER_MODEL" +USER_CONFIG_DIR = Path.home() / ".config" / "markitect" +USER_CONFIG_PATH = USER_CONFIG_DIR / "config.toml" +DIR_CONFIG_NAME = ".markitect.toml" + + +# ── App-name helpers ─────────────────────────────────────────────────────── + +def _model_env_var(app_name: str) -> str: + return f"{app_name.upper()}_HELPER_MODEL" + + +def _user_config_path(app_name: str) -> Path: + return Path.home() / ".config" / app_name / "config.toml" + + +def _dir_config_name(app_name: str) -> str: + return f".{app_name}.toml" + + +# ── Data classes ────────────────────────────────────────────────────────── + +@dataclass +class LLMLayer: + """One layer of provider/model configuration (may be partial).""" + provider: Optional[str] = None + model: Optional[str] = None + + +@dataclass +class ResolvedLLM: + """Fully-resolved provider + model with source attribution.""" + provider: str + model: str + provider_source: str + model_source: str + + +# ── Read / Write / Clear ───────────────────────────────────────────────── + +def _read_llm_section(path: Path, section: str) -> LLMLayer: + """Read ``[llm.
]`` from a TOML file. Returns empty layer on error.""" + try: + data = toml.load(path) + except (OSError, toml.TomlDecodeError): + return LLMLayer() + llm = data.get("llm", {}) + sec = llm.get(section, {}) + return LLMLayer( + provider=sec.get("provider"), + model=sec.get("model"), + ) + + +def _write_llm_section(path: Path, section: str, layer: LLMLayer) -> None: + """Merge ``[llm.
]`` into a TOML file. Creates dirs as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + + try: + data = toml.load(path) + except (OSError, toml.TomlDecodeError): + data = {} + + llm = data.setdefault("llm", {}) + sec = llm.setdefault(section, {}) + + if layer.provider is not None: + sec["provider"] = layer.provider + if layer.model is not None: + sec["model"] = layer.model + + with open(path, "w") as f: + toml.dump(data, f) + + +def _clear_llm_section(path: Path, section: str) -> bool: + """Remove ``[llm.
]``. Returns True if something was cleared.""" + try: + data = toml.load(path) + except (OSError, toml.TomlDecodeError): + return False + + llm = data.get("llm") + if not isinstance(llm, dict) or section not in llm: + return False + + del llm[section] + + # Clean up empty [llm] table. + if not llm: + del data["llm"] + + with open(path, "w") as f: + toml.dump(data, f) + return True + + +# ── Directory config path helper ───────────────────────────────────────── + +def _dir_config_path(app_name: str = "markitect") -> Optional[Path]: + root = find_project_root() + if root is None: + return None + return root / _dir_config_name(app_name) + + +# ── Resolution ─────────────────────────────────────────────────────────── + +def resolve_llm( + cli_provider: Optional[str] = None, + cli_model: Optional[str] = None, + app_name: str = "markitect", +) -> ResolvedLLM: + """Walk the 7-level priority chain and return a fully resolved config. + + Provider and model are resolved independently — each takes the value + from its highest-priority source. + + Args: + cli_provider: Provider override from CLI. + cli_model: Model override from CLI. + app_name: Application name used to derive config paths and the + env-var prefix (e.g. ``"railiance"`` → ``RAILIANCE_HELPER_MODEL`` + and ``~/.config/railiance/config.toml``). + """ + dir_path = _dir_config_path(app_name) + user_cfg = _user_config_path(app_name) + env_var = _model_env_var(app_name) + + # Build the layers (highest priority first). + layers: list[tuple[str, LLMLayer]] = [] + + # 1. CLI flags + layers.append(("CLI flag", LLMLayer(provider=cli_provider, model=cli_model))) + + # 2. Env var (model only) + env_model = os.environ.get(env_var) or None + layers.append((f"env {env_var}", LLMLayer(model=env_model))) + + # 3. User preference + layers.append(( + "user preference", + _read_llm_section(user_cfg, "preference"), + )) + + # 4. Directory preference + if dir_path: + layers.append(( + "directory preference", + _read_llm_section(dir_path, "preference"), + )) + + # 5. Directory default + if dir_path: + layers.append(( + "directory default", + _read_llm_section(dir_path, "default"), + )) + + # 6. User default + layers.append(( + "user default", + _read_llm_section(user_cfg, "default"), + )) + + # 7. Hardcoded + layers.append(("hardcoded", LLMLayer(provider=HARDCODED_PROVIDER, model=HARDCODED_MODEL))) + + # Resolve provider and model independently (first non-None wins). + provider = HARDCODED_PROVIDER + provider_source = "hardcoded" + model = HARDCODED_MODEL + model_source = "hardcoded" + + for source, layer in layers: + if layer.provider: + provider = layer.provider + provider_source = source + break + + for source, layer in layers: + if layer.model: + model = layer.model + model_source = source + break + + return ResolvedLLM( + provider=provider, + model=model, + provider_source=provider_source, + model_source=model_source, + ) + + +def get_default_layers(app_name: str = "markitect") -> list[tuple[str, LLMLayer]]: + """Return only the default layers for display.""" + dir_path = _dir_config_path(app_name) + user_cfg = _user_config_path(app_name) + dir_cfg_name = _dir_config_name(app_name) + layers: list[tuple[str, LLMLayer]] = [] + + if dir_path: + layers.append(( + f"Directory default ({dir_cfg_name})", + _read_llm_section(dir_path, "default"), + )) + + layers.append(( + f"User default ({user_cfg})", + _read_llm_section(user_cfg, "default"), + )) + + layers.append(( + "Hardcoded", + LLMLayer(provider=HARDCODED_PROVIDER, model=HARDCODED_MODEL), + )) + + return layers + + +def get_preference_layers(app_name: str = "markitect") -> list[tuple[str, LLMLayer]]: + """Return only the preference layers for display.""" + dir_path = _dir_config_path(app_name) + user_cfg = _user_config_path(app_name) + dir_cfg_name = _dir_config_name(app_name) + layers: list[tuple[str, LLMLayer]] = [] + + layers.append(( + f"User preference ({user_cfg})", + _read_llm_section(user_cfg, "preference"), + )) + + if dir_path: + layers.append(( + f"Directory preference ({dir_cfg_name})", + _read_llm_section(dir_path, "preference"), + )) + + return layers diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..21c697c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "llm-connect" +version = "0.1.0" +description = "Pluggable LLM adapters for OpenRouter, Gemini, OpenAI and Claude Code CLI" +requires-python = ">=3.10" +dependencies = [ + "toml", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["llm_connect*"]