From 10e9da9b32d0ea3c48d5c6ca17c6c95ecde4712b Mon Sep 17 00:00:00 2001 From: egregore Date: Mon, 2 Feb 2026 19:51:16 +0000 Subject: [PATCH] Initial relay service setup API gateway that relays authenticated requests to backend services. Features JWT auth, rate limiting, scope-based access control. Co-Authored-By: Claude Opus 4.5 --- __pycache__/main.cpython-311.pyc | Bin 0 -> 13977 bytes main.py | 314 +++++++++++++++++++++++++++++++ requirements.txt | 8 + 3 files changed, 322 insertions(+) create mode 100644 __pycache__/main.cpython-311.pyc create mode 100644 main.py create mode 100644 requirements.txt diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73d59e60f809f24d12722a76e79fe3a60fb7c594 GIT binary patch literal 13977 zcmZ3^%ge>Uz`(%I(3tsKk%8echy%l%P{!wJYzz$38B!Rc7*ZHhm~t4S7{N4C6jKUg z3Udx~E^`!fE=v?kE^8DkBS;NP4qGmJ6g!yBn!}OH8O52)6~&dy9mSo?6U74-XUpNu z<%{CW<&WYA^VxF*as{IV!EBBkp`%axpO3Q zrJ|(3Y^EIPC~2^}WTIriv}}|t6GN(S3ePe|28Pv)P~R{xGB7cuGG@ua_$j_6 z@^C&Ms`_P23=FH8V6ssPObn@tXeRKd2%xG$H6cm~iypxgA-sB&(d-aT5kXZSrGlnH zG(`+eg({i~@e~O(6>3ZjsiLV8Yb4QJtj@%c%9$dSBAp_$Ms^u91H)=&cnD~us;6pX zX~OvN>M&NS#sV!E8<|d(N=0)SI1czyEK8lpYg< zJ41?63qy)>svw%{^ucl}Eet8D7;*++Ikgss6m<+aL$I7i3qy(~hMZA~R;qD|b}D*!GQ}j-IE^XAw1p+gDpg~FHQZVxMv57#DJkYH49gf87*@m8 zMA@WjEU<-hkr*i!sRnB-QBy;TRjPg(Q;Ky93z{i*I8CuZbwR570(&G&;LH?TRCS;* zU}8wMO0h!~N9Cn4rr4+Qq*$dIrZ}XUq_L$qwy>hQ43!tkgx6v0>9F>>G2=*OQiXVne&N$P7KdKoib}26DOlgd;yphI|5`bZvOR8&1U^cj9 zEs_M6*ltm7Obn@RS?;ield6#tl&ZWY7&VVGGNdpCgRy2vRUwyadQob6eo?AIP-;$M zr9yCOQCVhks)DY9V}PfEdtymyd156O7g(}bA+fY1BQ>uiGdZy&H3h7qD7COOwYa2M zp(I}+DKR-aH7`Y>7-B}T9#>FlUa>-coQHg?ufrY6a*GrJ={4|+waik=cq?Tmn zrrzQNktwM;C5g8Ja6%OgoFgRRwSo_ z3T)ro?9?=B4DM7HKlx5=u`ki7!gc%`ZufPfSTEN-YK%Ef|zok_t8gjDVgZHXEv_I}$6$ZI_|PDqD$$IRk^(DzeM2L2Jp(-hJwq!C0}Dft5@$yr zAKXfeZt*z#`-Qm%1-s%_WB`hX?9|Hmw9>rfDlzPdrHa)MWOEg(eoB6Fu_p5^){@MU zoYY(FWvNBQnfZCQI6&g@rA0YznvA!&5=%?+<5P=@@{7_zX@P+u&S53vEp|}M=a-fw zL&P8?tlIo6#Q7VSXRuoAUNyq$)l}A0mJ}wirMhn1P>9^( zO06i!EJ`ho&&<2U2F(>*V5#_$%7RosP4*%+kmEt^kRlBb3)D6#0yRZ$F=ytL6d8lW zv_S;e;kqD}K8$c=U|=XVVPHV=Yy-m$ZoUSu2Cqi18v?=&{tf<({x`(r8oVYjUtp2D zz#?}MOkQA-gM9K!Lna1R_9%axthKf=Cq&4DfVuLr}ZHwZr%Vi}nQ; z?TakhH>6}5{3bYGV3E1NB6AT;USN@dgcry(?BQjHGrYiI1xq=sscfn2pd<=P-3$z= zEGdvw#2n0^$$E>|6_hiBQemkGoKcI+85kJ+Zn3ANmLz886ypmIkRyuNKt&l#N@j8i zdVqjps0c*(f>I7B@K6E-o^oJ0=7yY7gL_BBgp{7R3oJ?(Sd=b;$qOt>kk9}bRip~C zj;$!Qq_ik68QxW4U|;~b15}25-Ukkv5{Qq%-3z&gMpqf*|NQ^4f=425|4O21`BSSDlk8=uRFoPyjm1-|^xU(~U^qGNlJ+wKat-3JCnPN^Hhk{!Mv_<@;`Q|bXXe}@S; zqHl3R92K9La*M4vIlmyaI2qjdfd)J~0|Nudy`MKhVj3xSv*7F!q_{%FA!_5RmZ^pz zg|UXIhN%SBg=b)3C;^oVP^;E3q3Wn&U|^_WOktkGTFYF*3suCxz>o!U6PR1WTp|Ew zfQb~AHLR#jGEnhdrRZFgnpl#mkf`7l9-;s)p%v2dixf&SQlZg%iz5}$;j2@ zECL18E#|!Z@*+^8zQtTxl3WA|nj%q9Mq2bWNf&eV!2H5a*5u5hU|I6vT*xWO-Z zgJ0+dztGgkrBxS1jV_8BT@f|9$ZvE*Q2YZo8$a6z1~z`S2KTRg3_ScjRuj_aR&5Bs zz-@bx+x7~#?FANFaIz>eU|?WC$?_l{f&BM*0yrUXGL*ohpMe396lxfd6GIAPI^!B9 z)JUmitYHjhP-Z|bG!ThKg`tRrks*>Hg*g~i1t?I!(TuF3oS}j_lA)YYlch=)nu$S? zjFf?)VOj*rQs71sEV_#QG+B!xK;h1ok_yVNMW9%@CF%;U)>DEr)AJHbN{dp##r7>h z&%Cn4oXiw(QvfWa$pj9gqDWA5fN~5hLvukhG$_6j7#J8bK?+jA@%4d)jaB5XkmwYx z8KFzPcDV1byddOyQONa*kZT9~4Sul>z8`nF`L1xQd|+T?6+sX;_=P)4I!eHD3J75^ z3o7@4nUPiG$B!SyA`A=+O>&A13;7*cnK$z}vN9iJVRU3=zQvu4XrZITET}MVW?*3W z+{M7a;LX6uFrA@>p;!btenI`96y_9`IZSI`@%(MaS(LlWr4{>x6D1CtP zLUAnv1H%i37Yq#yA2=9z#6K{wuyWnylfEG#eM45~f@Syx;fM>u5g%BY`MEwaF!OVL z0TCYgIx@Cu({gZm9`A$0x07lcDD2!~+P|AC8vN4UWcobzvS zfV!TUMX4z$i3F7WKoR{JRA{v`Oh?K7CGbdRU_g!bG{zK0VnF@>px7nC4^ z88n%z^pGMsEit(yzX%-J;Mx>iP{Z7#04{oqJkU!fP<1dFlzuB17#OB9!cy=8a4JM6 zQW(=2P*ZR%V+vCZV;Ms+ODC8vVya;XW7&+Q~GVA%$%&a|-($rZpU>W;8Ks za{3hogGv)dt<1@w)F+ggS6rHwmYED{K*WQcUd07!H-kD23hGtt>QMiHiiV;zaH_e* zoSs?&PD`51V1E{YYlkdQ2?7dy1%(Dkqk$V7I#5%UpaBHo>e=MvCnx3<+vy>x0p;vs zP~-LoB!S%!6rV0NNot1sf}o3n8dn50I@oS-^G#6euH+;|V0fx&aMjTHqNd9gO_vW0?5td1VyOz4!c7P;@Q7bv5d*vB7rTB& zYGO`F1}FgF=>b%qflECTMp&tr!dT0Y!dT15iBj`mZ_pwq>e&n_Ommr1nAfnNrix~U z6xM3yFox;OZ7j7+HB5+FE`<#;!c@zU$F9r(s_*icAvGCMc7sYAs7ENUn-Sga8ipd4 z8pa}~6!tvE6pj?m8U`qv3ogo)!aav6g=Y?^Ps3ZK-~kR-1#n7JD9Se%~+qLUMIax|H5u|W!EP%8~Qo_31^IyeSuuW^G$Y+%J|KBzuVf(-UAz0apL5ps^Cruuy8wE#{)sLX^q`-r@jtA0g#6a%-aosXd8Woq#HhViDvnBi@z? zsAhoXlN!bpHc)DSaBCP-kb3H#QNP6DjK zX$Yz+*uecna0e4yUlf6Q21TGDA8=nolewq`l>R|=CAgW2nz%VZ1FI0nwKFg~d8H+(w^%>|x7b1TLuzpexIRn<)q;>HWMBZ5g5VNdg9%oG6IFVGnlex`W;3KP z&PD5XHZ!C!!AouC8s-Jac0%-m3hx}IHGHUfsEM(Lm6&h?_X8!shJlGJcsE~#p-8Qk4aGM8 z6oC?1ure@F!-iTmV0T9fQ!QH^TR9`VrzlvZ6bzb7NmPK2U4n;uKwS$*PX$=PRVAno zl`YoK$Sel6N&RlILs_?2N{dsAG?{L(6y<|PcMmsyOruit>{|lN8L5(aUa7 z76c7pYBJtp$tupzD+0CiGrxhFhrG~w3o>9|B@zU#>0nbl3Xsu3cq#DmJ4lZ(Ob>D` z$zN0lvIbn;fhxJ8eo*bomRJCqzXA7WZm|^P7nc-)Ne zs5J*B4uY!DY({t|M?j91>#3~9{PBlSA;A&*gvo_a0>MEb@44=+hDxK`hvLq1#$a}91d4F z94>G;d}U*h)wnLBb4f<$F2Cdj9%Tr6ASp9P@`kk14JFkpN+uuJS%mmLFn|bF23g&Y z4D7O8UqHkM26k~!+Y>v{!Tmvqfk*rbx6*>-OWc|lxHazz$={HXzoBSw!8Z7Uc*q5D zP{&J{?;``VFy9vt@qvMvm+u1?gMjpPe)&uM@)!9Pukb5g;86VWlw05mw=B5CMG&xN zxY6F?1BN@x_LN-^cDo?#hOGep0cu4sv#@f3h66yEtHoK|S%%@T48MyY^I>g87ary# zJghE)pnAK^5SG4Qg62mcQOXLQk-No~l$w@bl#0E~RYPhuqt@LiOsHi+CSxsg4Py~& z4Py;+3V2?DlL5KIxB%1}0Ox3Aq6Ddgo6V5IGMA}_8GY=bnIVM@UQ**MSy6|K!6gp7 zTw`F!g4+Wwaac?E5w1vKPvIyLMDS}^(MlZ7IZSJ~P<`9Pn8H@eTE~i1;&4|fyQh}G ziyLSbf;5*464NvDz_TGmvl$o|UZ#NZ+Z+Z4hAK{Y3AB>&7F$7LQDSbfU(rO6{3H+o zFB>#DZ!za)=3y%ail%~8f|{?8Mgllfvw>|Y0$BpCQ;R?y<)TKA%9)@X3JMTN28Q-E zL5&5dql*?XFfhCVsoDc?E_~o);1`=(Ji~N>^2GWn^%r>68{BTN^MVGalx|2XcCd9+ zT;!1Wz{1YTh15{Cy`Tz02huQ3uNne&PPguFB5pp6Ux6Qa|oeGh=}2 zvAMR6(hLWs`JDuq4{9koaWfy{W_1!^t`Y(@N1$HTFHTJ?O3ru*8dtl;ScpA0{DP01 zk=FvL0S$CB&0$)@jGCI78B$o_DV)f*2snj<3U+A1MYJlwDVzm)C_RNWg$+5y*RY_a zaI9k;DJ-=tbu36JoU=+S7#vFIiM&bxo{FL2sL52c1eDZ3sl8}Es5^)?O>+>PoLP~~ z12=aODH+t90;gn1Lgs_SWIQxti$Dd#e^6T9ijjx zvN~EZS8;+;aY=rDPBCcCIzOivr4I&5A>h{Y2MW_JYKlQ=Lo(Jd65R>}xd(fSWkN}@ z%qc7=DHg4r3~D*AVMEPDO^hi_wM=zPNGX=RN(@vc!v`A_96c4lK~=@352mpfwaCd5 z+@1n8wy>76%#gAbkr+YkXIPu)7PJGFpHp14l7WFin2~{@7*wi5M&~}Tp_Y|a7gRy$ zqNw#1QR@r*=!o{^WQ(;U1H(ZEeo)%tS9COBK4`$|Xv$p02VHUl8OcVSI0R)rwDgt2 zh}=>@%o(Mi#o=s*xlA}GhMBPUpFo)zvBD5}gaA?gp{hYmBMU&)9yI+SC{!J_%;0hw zG!Mdz+}ElBw`yt_YM5#mQ&_=cV{BC>L8$pIP> zOD;++DZa%C?%2m?q*fHUfV%>qx#xm%@XX^a_Vm=e;>4ViBGBw|krT)cLcOQrXGCuQS#Ciiqlhq>Cb&S41=~@M&M*({6CT z%P-nfJ0pC4+^o2ZvbtAfb=Q`ysk^9ee?{N^BEQ2GeuwM)?w9!8FY> zCVxXn;)a;y2X+=&whs&}vTUv16F4riNPgf0$q7r{5R&-7&H@^qf)K21;DH3tq9oAL zq4@YJPWZYlP){u*zqka{4=O6r^tid(nz)2UZ+a?dU0u;SkQ`_Vvk1Ii1~gb#1Zv|z`hDP@p)|-Mha4loap^Jn-s2*b=;3yh-5Ie(}Z08L7Fcx40dPEAx__!Al|_^)n~| z6@k}ifrp+zOK8B0P{7?s@VGyCoaz=!PG)flWQ+})*zyO-2 zF1BT3VEDky$jJDCg_V)z0|SWQV_@WJ-~_=N415h>c!R;{0&aAJLHYtJdca_Ffe^aE z;CcZYy1^iK0Tp2rvAn>51>IoKzJQ8uFz8%BMK>6D8o=-d16KnW-eBNu0K*S#42(Pt z+!uJ{E;7hpVUWMTApd|@tRtu=azf1oRkH=*S5(b56kSxcx}s`zk=ObPuXO|04PJqc z;0clwgr-Q&uvsB?QPALuput66!z;Xo4O}<)g*u8SSWYmSVmTvjMc74QlPkg|7x_)E z@S8U9d|+eX6NedYbCK8f3a>55Jh+7jF@A}TqMphbau+lmF7i8G;dcb<<`(KuzaXu9 zflKcqm);dFy$1FNY`hKWH@K8;@Ce`F75Tu4dRO@L8n{1jG4KmEaDU)nWEA|sfJscS{0I{L0wN&t9L$XJ9~dx+ z37#K8qF+D+M4pG4Q5M8PCnjiuRLX)>LZx_^8I?gQ(Fur|${>|cDLz(4ff>%AL@uzv zV@1dkKQI%W5a438o1y%Hfs4^@K?#`Jzzn9)2`LUnkqOBk7&sV3W@IcVnUe=*q7xDf zh87#xcko^`aJypQ_JLWDQTPLcAfxaNL0vHC0W0SRR+u1I2100XF^Yp6%f%=TvKX0= zVPIly7jF{pki5t!e1%c?1CsC?K%D0+oa^adkGyKIweNA*QU=_`!VA2=99q$ilq zu$^JMLhPcn!4+wPi^7IigbhEit1xnXU{GP?0wq3X6-KTHoFX4sL4s@yyn+a6uo{q^ zGFKR-LA2NvM$r!}AccO6AV-5dCwq}m{0gJ^13}>%G72|@rJy!SUX(VzB5e%GN*~w- zK{g69a^2wO0|h7710InH!9Bj9Am#c1u@$BS6s}w#+a#_qihp1N32Q(tyTB=Xkx}jn zqud8J1|bogmI;6?6JX@J!Or!8S%8u20XN?VR*)dXA`puKMu04m2bstO5>bO%)FBNG zQe~|L<|}Mh*zOR&sO@+~+wr2Z(-mc>4-A|jD>xasX1E~OU>3+ca4<1}lt_WhlW3B- zz$FEZ6gCD?=^36Egw;N<3WHLEFeBRyexVP{!i;PWcmxoF9~guo=>!z^ADBRDbr@K9 zI;t))OI~1>yul*W;0J;?Soj; {client_id, scopes, rate_limit} +API_CLIENTS = {} + + +# Request/Response models +class TokenRequest(BaseModel): + api_key: str + + +class TokenResponse(BaseModel): + token: str + expires_in: int + token_type: str = "Bearer" + + +class ChatRequest(BaseModel): + message: str + model: str = "claude-sonnet-4-20250514" + max_iterations: int = 10 + + +class ErrorResponse(BaseModel): + error: str + message: str + details: Optional[dict] = None + + +# Helper functions +def verify_api_key(api_key: str) -> Optional[dict]: + """Verify API key and return client info""" + for key_hash, client in API_CLIENTS.items(): + if bcrypt.checkpw(api_key.encode(), key_hash.encode()): + return client + return None + + +def create_jwt(client_id: str, scopes: list) -> str: + """Create a JWT token for the client""" + now = datetime.now(timezone.utc) + payload = { + "sub": client_id, + "iss": "egregore", + "iat": now, + "exp": now + timedelta(seconds=JWT_EXPIRY), + "scope": scopes + } + return jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +def verify_jwt(token: str) -> Optional[dict]: + """Verify JWT and return payload""" + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +async def get_current_client( + credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme) +) -> dict: + """Dependency to get current authenticated client from JWT""" + if not credentials: + raise HTTPException( + status_code=401, + detail={"error": "invalid_token", "message": "Missing authorization header"} + ) + + payload = verify_jwt(credentials.credentials) + if not payload: + raise HTTPException( + status_code=401, + detail={"error": "invalid_token", "message": "Invalid or expired token"} + ) + + return payload + + +def require_scope(required: str): + """Dependency factory to check for required scope""" + async def check_scope(client: dict = Depends(get_current_client)): + scopes = client.get("scope", []) + if "*" in scopes or required in scopes: + return client + raise HTTPException( + status_code=403, + detail={"error": "insufficient_scope", "message": f"Requires '{required}' scope"} + ) + return check_scope + + +# Public endpoints +@app.get("/health") +async def health(): + """Health check with backend status""" + reason_ok = False + recall_ok = False + + try: + resp = await http_client.get(f"{REASON_URL}/health", timeout=2.0) + reason_ok = resp.status_code == 200 + except: + pass + + try: + resp = await http_client.get(f"{RECALL_URL}/health", timeout=2.0) + recall_ok = resp.status_code == 200 + except: + pass + + return { + "status": "ok" if (reason_ok and recall_ok) else "degraded", + "service": "relay", + "backends": { + "reason": "ok" if reason_ok else "unavailable", + "recall": "ok" if recall_ok else "unavailable" + } + } + + +@app.post("/auth/token", response_model=TokenResponse) +async def get_token(req: TokenRequest): + """Exchange API key for JWT token""" + client = verify_api_key(req.api_key) + if not client: + raise HTTPException( + status_code=401, + detail={"error": "invalid_api_key", "message": "API key not found or revoked"} + ) + + token = create_jwt(client["client_id"], client["scopes"]) + return TokenResponse(token=token, expires_in=JWT_EXPIRY) + + +# Protected endpoints +@app.post("/v1/chat") +@limiter.limit("10/minute") +async def chat( + request: Request, + req: ChatRequest, + client: dict = Depends(require_scope("chat")) +): + """Send a message and get AI response""" + try: + # Get conversation history + history_resp = await http_client.get(f"{RECALL_URL}/messages/history") + history = history_resp.json().get("history", []) + + # Add user message to history + history.append({"role": "user", "content": req.message}) + + # Process with reason service + reason_resp = await http_client.post( + f"{REASON_URL}/process", + json={ + "model": req.model, + "history": history, + "max_iterations": req.max_iterations + } + ) + + if reason_resp.status_code != 200: + raise HTTPException( + status_code=502, + detail={"error": "backend_error", "message": "Reason service error"} + ) + + return reason_resp.json() + + except httpx.RequestError as e: + raise HTTPException( + status_code=503, + detail={"error": "backend_unavailable", "message": str(e)} + ) + + +@app.get("/v1/history") +async def get_history( + limit: int = 50, + before: Optional[int] = None, + client: dict = Depends(require_scope("history")) +): + """Get message history with pagination""" + params = {"limit": min(limit, 100)} + if before: + params["before"] = before + + try: + resp = await http_client.get(f"{RECALL_URL}/messages", params=params) + return resp.json() + except httpx.RequestError as e: + raise HTTPException( + status_code=503, + detail={"error": "backend_unavailable", "message": str(e)} + ) + + +@app.get("/v1/history/search") +async def search_history( + q: str, + limit: int = 20, + client: dict = Depends(require_scope("history")) +): + """Search message history""" + try: + resp = await http_client.get( + f"{RECALL_URL}/messages/search", + params={"q": q, "limit": limit} + ) + return resp.json() + except httpx.RequestError as e: + raise HTTPException( + status_code=503, + detail={"error": "backend_unavailable", "message": str(e)} + ) + + +@app.get("/v1/tools") +async def get_tools(client: dict = Depends(require_scope("tools"))): + """Get available AI tools""" + try: + resp = await http_client.get(f"{REASON_URL}/tools") + return resp.json() + except httpx.RequestError as e: + raise HTTPException( + status_code=503, + detail={"error": "backend_unavailable", "message": str(e)} + ) + + +# Admin endpoint to register API keys (temporary, move to proper admin) +@app.post("/admin/clients") +async def register_client( + client_id: str, + scopes: list = ["chat", "history"], +): + """Register a new API client (temporary admin endpoint)""" + # Generate API key + api_key = f"eg_{secrets.token_hex(20)}" + key_hash = bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode() + + API_CLIENTS[key_hash] = { + "client_id": client_id, + "scopes": scopes, + "rate_limit": 100 + } + + return { + "client_id": client_id, + "api_key": api_key, # Only shown once! + "scopes": scopes + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=GATEWAY_PORT) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5b6177f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +python-dotenv>=1.0.0 +httpx>=0.24.0 +pyjwt>=2.8.0 +bcrypt>=4.0.0 +slowapi>=0.1.9 +pydantic>=2.0.0