ghostdrive1 commited on
Commit
65d8e8c
·
1 Parent(s): be4911f

feat: add llm_router.py — multi-provider router (Groq/Cerebras/Together/OpenRouter/Gemini) session v16

Browse files
Files changed (1) hide show
  1. packages/brain/llm_router.py +421 -0
packages/brain/llm_router.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/brain/llm_router.py
3
+
4
+ Ultron V4 — Multi-Provider LLM Router
5
+ ======================================
6
+ PROBLEM THIS FILE SOLVES (v16 — Ghost clarification):
7
+ task_dispatcher.py's _make_groq_llm_fn() hardcoded Groq's endpoint.
8
+ Key rotation pool has 5 providers, but calls never reached Cerebras/Together/
9
+ OpenRouter/Gemini. Result: pool circuit breaker useless, 1/5th of free quota used.
10
+
11
+ FIX:
12
+ This module provides make_provider_llm_fn(pool) — a single async call function
13
+ that reads key_obj["provider"] from pool.get_key() and routes to the correct
14
+ API endpoint + format for each provider. task_dispatcher.py imports THIS instead
15
+ of defining its own Groq-only function.
16
+
17
+ Provider support matrix:
18
+ groq → api.groq.com/openai/v1/chat/completions (OpenAI-compat, JSON mode, tool_calls)
19
+ cerebras → api.cerebras.ai/v1/chat/completions (OpenAI-compat, JSON mode, tool_calls)
20
+ together → api.together.xyz/v1/chat/completions (OpenAI-compat, JSON mode, NO tool_calls)
21
+ openrouter → openrouter.ai/api/v1/chat/completions (OpenAI-compat, JSON mode, tool_calls)
22
+ gemini → generativelanguage.googleapis.com (different API shape entirely)
23
+
24
+ Key rotation pool interface assumed:
25
+ key_obj = await pool.get_key()
26
+ key_obj = {"key_id": str, "key": str, "provider": str, "model": str}
27
+ await pool.report_success(key_id: str)
28
+ await pool.report_failure(key_id: str)
29
+ raises AllKeysExhaustedError when pool is empty
30
+
31
+ Council/MOA note (v16 LOCKED DECISION):
32
+ Council calls spread across ALL providers, NOT all-Groq or all-Gemini.
33
+ Each expert call = pool.get_key() → could be any provider.
34
+ This maximises free quota: 5 providers × 5 keys each = ~25 keys total.
35
+ Ghost has 5 Gmail accounts → one key per provider per account = full coverage.
36
+
37
+ Future bug risks (pre-registered):
38
+ R1 [HIGH] Together AI does NOT support tool_calls (function calling).
39
+ If pool routes a tool-using ReAct step to Together → response has no
40
+ tool_call block → react_loop parser gets plain text → falls back to
41
+ action_type=done prematurely. Fix: together_call() strips tools param,
42
+ prompts model to embed action JSON in text → parser handles both.
43
+ R2 [HIGH] Gemini API shape is completely different (not OpenAI-compat).
44
+ gemini_call() must translate messages[] → Gemini contents[] format.
45
+ Role mapping: system→system_instruction (Gemini 1.5+), user→user,
46
+ assistant→model. Failure to remap = 400 Invalid JSON.
47
+ R3 [MED] OpenRouter adds rate limits per model, not per key. Even with 5 keys
48
+ pointing to same model, rate limit hits. Fix: vary model per key_obj
49
+ (mistral-7b for key1, llama-70b for key2 etc).
50
+ R4 [MED] Cerebras returns tool_calls in a slightly different nesting than Groq.
51
+ Must handle both choices[0].message.tool_calls and
52
+ choices[0].message.content with embedded JSON.
53
+ R5 [LOW] pool.get_key() is async but some callers may forget await → TypeError.
54
+ Already guarded here but watch for copy-paste in council.py.
55
+
56
+ Tool calls used this session:
57
+ Github:push_files x1, Notion:notion-update-page x1
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import logging
64
+ from typing import Any, Optional
65
+
66
+ import httpx
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Provider endpoint config
72
+ # ---------------------------------------------------------------------------
73
+
74
+ PROVIDER_CONFIG: dict[str, dict] = {
75
+ "groq": {
76
+ "url": "https://api.groq.com/openai/v1/chat/completions",
77
+ "default_model": "llama-3.3-70b-versatile",
78
+ "supports_tools": True,
79
+ "supports_json_mode": True,
80
+ "auth_header": "Bearer",
81
+ },
82
+ "cerebras": {
83
+ "url": "https://api.cerebras.ai/v1/chat/completions",
84
+ "default_model": "llama3.1-70b",
85
+ "supports_tools": True,
86
+ "supports_json_mode": True,
87
+ "auth_header": "Bearer",
88
+ },
89
+ "together": {
90
+ "url": "https://api.together.xyz/v1/chat/completions",
91
+ "default_model": "meta-llama/Llama-3-70b-chat-hf",
92
+ "supports_tools": False, # Bug R1: no function calling on Together
93
+ "supports_json_mode": True,
94
+ "auth_header": "Bearer",
95
+ },
96
+ "openrouter": {
97
+ "url": "https://openrouter.ai/api/v1/chat/completions",
98
+ "default_model": "mistralai/mistral-7b-instruct",
99
+ "supports_tools": True,
100
+ "supports_json_mode": True,
101
+ "auth_header": "Bearer",
102
+ "extra_headers": {
103
+ "HTTP-Referer": "https://github.com/ghostdriveg1/ultron-v4",
104
+ "X-Title": "Ultron",
105
+ },
106
+ },
107
+ "gemini": {
108
+ # Gemini uses a completely different REST shape — handled separately
109
+ "url": "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
110
+ "default_model": "gemini-1.5-flash", # flash for speed/cost; pro for Sentinel
111
+ "supports_tools": True,
112
+ "supports_json_mode": True,
113
+ "auth_header": "key", # query param not header
114
+ },
115
+ }
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # OpenAI-compatible call (Groq / Cerebras / Together / OpenRouter)
120
+ # ---------------------------------------------------------------------------
121
+
122
+ async def _openai_compat_call(
123
+ url: str,
124
+ api_key: str,
125
+ model: str,
126
+ messages: list[dict],
127
+ tools: list[dict],
128
+ supports_tools: bool,
129
+ supports_json_mode: bool,
130
+ extra_headers: Optional[dict] = None,
131
+ ) -> Optional[dict]:
132
+ """
133
+ Single async call to any OpenAI-compatible endpoint.
134
+ Returns normalized dict: {"content": str} or {"tool_name": str, "tool_args": dict}
135
+ Returns None on failure.
136
+
137
+ Bug R1: if supports_tools=False (Together), we drop the tools param and
138
+ instead inject tool schema into the system prompt as JSON, then parse
139
+ the model's text response for action_type + action_params.
140
+ """
141
+ headers = {
142
+ "Authorization": f"Bearer {api_key}",
143
+ "Content-Type": "application/json",
144
+ }
145
+ if extra_headers:
146
+ headers.update(extra_headers)
147
+
148
+ payload: dict[str, Any] = {
149
+ "model": model,
150
+ "messages": messages,
151
+ "max_tokens": 512,
152
+ "temperature": 0.3,
153
+ }
154
+
155
+ if supports_json_mode:
156
+ payload["response_format"] = {"type": "json_object"}
157
+
158
+ if supports_tools and tools:
159
+ payload["tools"] = tools
160
+ payload["tool_choice"] = "auto"
161
+ elif not supports_tools and tools:
162
+ # Bug R1 mitigation: Together / no-tool providers
163
+ # Inject tool list as system message instruction
164
+ tool_names = [t["function"]["name"] for t in tools]
165
+ tool_inject = (
166
+ f"\n\nYou MUST respond with JSON only. Choose one action from: {tool_names}. "
167
+ f"Format: {{\"action_type\": \"<tool_name>\", \"action_params\": {{...}}, \"memory\": \"...\"}}"
168
+ )
169
+ # Append to last system message or prepend new one
170
+ msgs = list(messages)
171
+ if msgs and msgs[0]["role"] == "system":
172
+ msgs[0] = {"role": "system", "content": msgs[0]["content"] + tool_inject}
173
+ else:
174
+ msgs.insert(0, {"role": "system", "content": tool_inject})
175
+ payload["messages"] = msgs
176
+
177
+ try:
178
+ async with httpx.AsyncClient(timeout=30) as client:
179
+ resp = await client.post(url, headers=headers, json=payload)
180
+
181
+ if resp.status_code == 429 or resp.status_code >= 500:
182
+ logger.warning(f"[LLMRouter] {url} returned {resp.status_code}")
183
+ return None # caller reports failure to pool
184
+
185
+ resp.raise_for_status()
186
+ data = resp.json()
187
+
188
+ choice = data["choices"][0]["message"]
189
+
190
+ # Handle tool_calls (Groq / Cerebras / OpenRouter)
191
+ tool_calls = choice.get("tool_calls") or []
192
+ if tool_calls:
193
+ tc = tool_calls[0]
194
+ try:
195
+ args = json.loads(tc["function"].get("arguments", "{}"))
196
+ except json.JSONDecodeError:
197
+ args = {}
198
+ return {
199
+ "content": json.dumps({
200
+ "memory": "",
201
+ "action_type": tc["function"]["name"],
202
+ "action_params": args,
203
+ })
204
+ }
205
+
206
+ # Plain text / JSON content
207
+ return {"content": choice.get("content", "{}")}
208
+
209
+ except Exception as exc:
210
+ logger.error(f"[LLMRouter] openai_compat_call error ({url}): {exc}")
211
+ return None
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Gemini-specific call (Bug R2: completely different API shape)
216
+ # ---------------------------------------------------------------------------
217
+
218
+ async def _gemini_call(
219
+ api_key: str,
220
+ model: str,
221
+ messages: list[dict],
222
+ tools: list[dict],
223
+ ) -> Optional[dict]:
224
+ """
225
+ Calls Google Gemini REST API. Translates OpenAI message format → Gemini format.
226
+
227
+ Bug R2: role mapping is non-trivial.
228
+ OpenAI system → Gemini system_instruction (separate field, not in contents[])
229
+ OpenAI user → Gemini role: "user"
230
+ OpenAI assistant → Gemini role: "model"
231
+ Missing this mapping = 400 error.
232
+
233
+ Returns normalized {"content": str} or None on failure.
234
+ """
235
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
236
+
237
+ # Translate messages
238
+ system_instruction = None
239
+ contents = []
240
+ for msg in messages:
241
+ role = msg.get("role", "user")
242
+ text = msg.get("content", "")
243
+ if role == "system":
244
+ # Gemini 1.5+: system goes to system_instruction
245
+ system_instruction = {"parts": [{"text": text}]}
246
+ elif role == "assistant":
247
+ contents.append({"role": "model", "parts": [{"text": text}]})
248
+ else:
249
+ contents.append({"role": "user", "parts": [{"text": text}]})
250
+
251
+ if not contents:
252
+ # Gemini requires at least one user turn
253
+ contents.append({"role": "user", "parts": [{"text": "Begin."}]})
254
+
255
+ payload: dict[str, Any] = {
256
+ "contents": contents,
257
+ "generationConfig": {
258
+ "maxOutputTokens": 512,
259
+ "temperature": 0.3,
260
+ "responseMimeType": "application/json", # JSON mode
261
+ },
262
+ }
263
+ if system_instruction:
264
+ payload["system_instruction"] = system_instruction
265
+
266
+ try:
267
+ async with httpx.AsyncClient(timeout=30) as client:
268
+ resp = await client.post(
269
+ url,
270
+ headers={"Content-Type": "application/json"},
271
+ json=payload,
272
+ )
273
+
274
+ if resp.status_code == 429 or resp.status_code >= 500:
275
+ logger.warning(f"[LLMRouter] Gemini {resp.status_code}")
276
+ return None
277
+
278
+ resp.raise_for_status()
279
+ data = resp.json()
280
+
281
+ # Gemini response: candidates[0].content.parts[0].text
282
+ try:
283
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
284
+ return {"content": text}
285
+ except (KeyError, IndexError) as exc:
286
+ logger.error(f"[LLMRouter] Gemini parse error: {exc} | raw={str(data)[:200]}")
287
+ return None
288
+
289
+ except Exception as exc:
290
+ logger.error(f"[LLMRouter] gemini_call error: {exc}")
291
+ return None
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Public: make_provider_llm_fn — replaces task_dispatcher._make_groq_llm_fn
296
+ # ---------------------------------------------------------------------------
297
+
298
+ async def make_provider_llm_fn(pool: Any):
299
+ """
300
+ Returns an async llm_call_fn(messages, tools) -> Optional[dict] bound to
301
+ the full key_rotation pool.
302
+
303
+ This is what task_dispatcher.py should import and pass to ReActLoop.
304
+ The pool selects any provider (Groq/Cerebras/Together/OpenRouter/Gemini).
305
+ The call is routed to the correct endpoint based on key_obj["provider"].
306
+
307
+ Council/MOA spread (v16 LOCKED):
308
+ Every Council expert call goes through this function.
309
+ Pool weighted RR ensures spread across providers automatically.
310
+ No code change needed for Council — just call this fn per expert.
311
+
312
+ Bug R5: pool.get_key() is async. Always await it.
313
+ """
314
+ async def llm_call_fn(
315
+ messages: list[dict],
316
+ tools: list[dict],
317
+ ) -> Optional[dict]:
318
+ if pool is None:
319
+ logger.error("[LLMRouter] No pool — cannot call LLM")
320
+ return None
321
+
322
+ key_obj = None
323
+ try:
324
+ key_obj = await pool.get_key() # raises AllKeysExhaustedError
325
+ except Exception as exc:
326
+ logger.error(f"[LLMRouter] Pool exhausted or error: {exc}")
327
+ return None
328
+
329
+ if key_obj is None:
330
+ logger.error("[LLMRouter] pool.get_key() returned None")
331
+ return None
332
+
333
+ provider = key_obj.get("provider", "groq").lower()
334
+ api_key = key_obj.get("key", "")
335
+ key_id = key_obj.get("key_id", "")
336
+ model = key_obj.get("model") or PROVIDER_CONFIG.get(provider, {}).get("default_model", "")
337
+
338
+ logger.info(f"[LLMRouter] routing to provider={provider} model={model}")
339
+
340
+ result = None
341
+ try:
342
+ if provider == "gemini":
343
+ result = await _gemini_call(
344
+ api_key=api_key,
345
+ model=model,
346
+ messages=messages,
347
+ tools=tools,
348
+ )
349
+ else:
350
+ cfg = PROVIDER_CONFIG.get(provider, PROVIDER_CONFIG["groq"])
351
+ result = await _openai_compat_call(
352
+ url=cfg["url"],
353
+ api_key=api_key,
354
+ model=model,
355
+ messages=messages,
356
+ tools=tools,
357
+ supports_tools=cfg["supports_tools"],
358
+ supports_json_mode=cfg["supports_json_mode"],
359
+ extra_headers=cfg.get("extra_headers"),
360
+ )
361
+ except Exception as exc:
362
+ logger.error(f"[LLMRouter] call error for provider={provider}: {exc}")
363
+ result = None
364
+
365
+ # Report outcome to pool
366
+ if result is None:
367
+ try:
368
+ await pool.report_failure(key_id)
369
+ except Exception:
370
+ pass
371
+ else:
372
+ try:
373
+ await pool.report_success(key_id)
374
+ except Exception:
375
+ pass
376
+
377
+ return result
378
+
379
+ return llm_call_fn
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Convenience: one-shot call without pool (for Sentinel / Council orchestrator)
384
+ # ---------------------------------------------------------------------------
385
+
386
+ async def call_provider(
387
+ provider: str,
388
+ api_key: str,
389
+ model: str,
390
+ messages: list[dict],
391
+ tools: Optional[list[dict]] = None,
392
+ ) -> Optional[dict]:
393
+ """
394
+ Direct provider call without pool. Used by Sentinel (Gemini-only, always)
395
+ and Council orchestrator when pinning a specific expert to a provider.
396
+
397
+ Council usage pattern:
398
+ expert_results = await asyncio.gather(
399
+ call_provider("groq", groq_key, GROQ_MODEL, msgs),
400
+ call_provider("cerebras", cerebras_key, CEREBRAS_MODEL, msgs),
401
+ call_provider("together", together_key, TOGETHER_MODEL, msgs),
402
+ call_provider("openrouter", openrouter_key, OR_MODEL, msgs),
403
+ call_provider("gemini", gemini_key, GEMINI_MODEL, msgs),
404
+ )
405
+ → 5 parallel expert opinions, all free-tier, none blocking each other.
406
+ """
407
+ tools = tools or []
408
+ if provider == "gemini":
409
+ return await _gemini_call(api_key, model, messages, tools)
410
+
411
+ cfg = PROVIDER_CONFIG.get(provider, PROVIDER_CONFIG["groq"])
412
+ return await _openai_compat_call(
413
+ url=cfg["url"],
414
+ api_key=api_key,
415
+ model=model,
416
+ messages=messages,
417
+ tools=tools,
418
+ supports_tools=cfg["supports_tools"],
419
+ supports_json_mode=cfg["supports_json_mode"],
420
+ extra_headers=cfg.get("extra_headers"),
421
+ )