1111from agno .knowledge .embedder .google import GeminiEmbedder
1212from agno .knowledge .embedder .ollama import OllamaEmbedder
1313from agno .knowledge .embedder .fastembed import FastEmbedEmbedder
14+ import hashlib
15+ import math
16+
1417# from agno.models.vllm import VLLM
1518from openai import AsyncOpenAI , OpenAI
1619# --------------------------------------------------
2629OPENAI_MODEL_ID = os .getenv ("LLM_MODEL" ) or os .getenv ("OPENAI_MODEL_ID" , "gpt-4o" )
2730OPENAI_BASE_URL = os .getenv ("LLM_BASE_URL" ) or os .getenv ("OPENAI_BASE_URL" )
2831LLM_GATEWAY_URL = os .getenv ("LLM_GATEWAY_URL" , "https://portfolio-llm-gateway.onrender.com/v1" ).strip ()
32+ LLM_GATEWAY_TIMEOUT = float (os .getenv ("LLM_GATEWAY_TIMEOUT" , "180" ))
33+ EMBED_MODEL_TYPE = os .getenv ("EMBED_MODEL_TYPE" , "fastembed" ).strip ().lower ()
34+ EMBED_DIMENSIONS = int (os .getenv ("EMBED_DIMENSIONS" , "384" ))
2935EMBEDDING_BASE_URL = os .getenv ("EMBEDDING_BASE_URL" )
3036VLLM_BASE_URL = os .getenv ("VLLM_BASE_URL" )
3137VLLM_CHAT_MODEL_ID = os .getenv ("VLLM_CHAT_MODEL_ID" )
@@ -128,11 +134,15 @@ class GatewayAwareOpenAIChat(OpenAIChat):
128134 def _gateway_config (self ):
129135 token = get_llm_gateway_token ().strip ()
130136 gateway_url = LLM_GATEWAY_URL .strip ()
137+ if gateway_url .endswith ("/" ):
138+ gateway_url = gateway_url [:- 1 ]
131139 return token , gateway_url
132140
133141 def get_client (self ):
134142 token , gateway_url = self ._gateway_config ()
135- if token and gateway_url :
143+ if token :
144+ if not gateway_url :
145+ raise RuntimeError ("LLM gateway token present but LLM_GATEWAY_URL is not configured" )
136146 logger .info (
137147 "Using request-scoped Portfolio LLM Gateway for model=%s base_url=%s" ,
138148 self .id ,
@@ -141,16 +151,18 @@ def get_client(self):
141151 return OpenAI (
142152 api_key = token ,
143153 base_url = gateway_url ,
144- timeout = self .timeout ,
145- max_retries = self .max_retries ,
154+ timeout = max ( float ( self .timeout or 0 ), LLM_GATEWAY_TIMEOUT ) ,
155+ max_retries = max ( int ( self .max_retries or 0 ), 2 ) ,
146156 default_headers = self .default_headers ,
147157 default_query = self .default_query ,
148158 )
149159 return super ().get_client ()
150160
151161 def get_async_client (self ):
152162 token , gateway_url = self ._gateway_config ()
153- if token and gateway_url :
163+ if token :
164+ if not gateway_url :
165+ raise RuntimeError ("LLM gateway token present but LLM_GATEWAY_URL is not configured" )
154166 logger .info (
155167 "Using request-scoped Portfolio LLM Gateway (async) for model=%s base_url=%s" ,
156168 self .id ,
@@ -159,13 +171,69 @@ def get_async_client(self):
159171 return AsyncOpenAI (
160172 api_key = token ,
161173 base_url = gateway_url ,
162- timeout = self .timeout ,
163- max_retries = self .max_retries ,
174+ timeout = max ( float ( self .timeout or 0 ), LLM_GATEWAY_TIMEOUT ) ,
175+ max_retries = max ( int ( self .max_retries or 0 ), 2 ) ,
164176 default_headers = self .default_headers ,
165177 default_query = self .default_query ,
166178 )
167179 return super ().get_async_client ()
168180
181+
182+ class LowMemoryHashEmbedder :
183+ """Dependency-free fallback embedder for small Render instances.
184+
185+ Produces deterministic, normalized vectors using hashed token/character
186+ features. It avoids loading an ONNX embedding model into the service.
187+ For this demo-oriented deployment, LanceDB can still perform vector/
188+ lexical retrieval without the memory spike caused by FastEmbed startup.
189+ """
190+
191+ def __init__ (self , dimensions : int = 384 ):
192+ self .id = "portfolio-hash-embedder"
193+ self .dimensions = dimensions
194+
195+ @staticmethod
196+ def _tokens (text : str ):
197+ text = str (text or "" ).lower ()
198+ tokens = []
199+ cur = []
200+ for ch in text :
201+ if ch .isalnum () or ch == "_" :
202+ cur .append (ch )
203+ elif cur :
204+ tokens .append ("" .join (cur ))
205+ cur = []
206+ if cur :
207+ tokens .append ("" .join (cur ))
208+ return tokens
209+
210+ def get_embedding (self , text ):
211+ vec = [0.0 ] * self .dimensions
212+ tokens = self ._tokens (text )
213+ if not tokens :
214+ return vec
215+ for token in tokens :
216+ for feature in (token , token [:3 ], token [- 3 :]):
217+ digest = hashlib .blake2b (feature .encode ("utf-8" ), digest_size = 8 ).digest ()
218+ idx = int .from_bytes (digest , "little" ) % self .dimensions
219+ sign = 1.0 if (digest [0 ] & 1 ) else - 1.0
220+ vec [idx ] += sign
221+ norm = math .sqrt (sum (x * x for x in vec )) or 1.0
222+ return [x / norm for x in vec ]
223+
224+ def get_embedding_and_usage (self , text ):
225+ return self .get_embedding (text ), {"prompt_tokens" : 0 , "total_tokens" : 0 }
226+
227+ def embed_documents (self , texts ):
228+ return [self .get_embedding (t ) for t in texts ]
229+
230+
231+ def create_embedder ():
232+ if EMBED_MODEL_TYPE in {"hash" , "low_memory" , "low-memory" }:
233+ logger .warning ("Using low-memory deterministic embeddings for constrained deployment" )
234+ return LowMemoryHashEmbedder (dimensions = EMBED_DIMENSIONS )
235+ return FastEmbedEmbedder (id = os .getenv ("FASTEMBED_MODEL" , "BAAI/bge-small-en-v1.5" ), dimensions = EMBED_DIMENSIONS )
236+
169237# --------------------------------------------------
170238# Model Factory
171239# --------------------------------------------------
@@ -178,8 +246,8 @@ def create_model_and_embedder():
178246 base_url = OPENAI_BASE_URL ,
179247 temperature = 0.1
180248 )
181- embedder = FastEmbedEmbedder ()
182- logger .info ("Using OpenAI-compatible model with FastEmbed local embeddings" )
249+ embedder = create_embedder ()
250+ logger .info ("Using OpenAI-compatible model with constrained local embeddings" )
183251 elif MODEL_TYPE == "VLLM" :
184252 if not VLLM_CHAT_MODEL_ID or not VLLM_BASE_URL :
185253 raise ValueError ("VLLM config missing" )
@@ -189,15 +257,15 @@ def create_model_and_embedder():
189257 api_key = VLLM_API_KEY ,
190258 temperature = 0.1
191259 )
192- embedder = FastEmbedEmbedder ()
260+ embedder = create_embedder ()
193261 logger .info (f"✅ Using vLLM model: { VLLM_CHAT_MODEL_ID } " )
194262 elif MODEL_TYPE == "Gemini" :
195263 model = Gemini (
196264 id = os .getenv ("GEMINI_MODEL_ID" ),
197265 api_key = os .getenv ("GOOGLE_API_KEY" ),
198266 temperature = 0.1
199267 )
200- embedder = FastEmbedEmbedder ()
268+ embedder = create_embedder ()
201269 logger .info ("✅ Using Gemini model" )
202270 elif MODEL_TYPE == "Ollama" :
203271 model = Ollama (
@@ -216,8 +284,8 @@ def create_model_and_embedder():
216284 supports_native_structured_outputs = True ,
217285 supports_json_schema_outputs = True ,
218286 )
219- embedder = FastEmbedEmbedder ()
220- logger .info ("✅ Using FastEmbed local embeddings" )
287+ embedder = create_embedder ()
288+ logger .info ("✅ Using configured local embeddings" )
221289 else :
222290 raise ValueError (f"Unsupported MODEL_TYPE: { MODEL_TYPE } " )
223291 return model , embedder
0 commit comments