File size: 25,068 Bytes
6993919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
"""
RMI Agent System β€” Agent MUNCH Multi-Specialist Intelligence Operative
======================================================================

9 specialized crypto intelligence operatives, each a distinct skill module
under the Agent MUNCH persona. Uses free OpenRouter models with fallbacks.

Architecture:
  - Each specialist has its own system prompt, model preference, and output format
  - RAG context injection: fetches real DataBus data before LLM call
  - Smart caching: checks Redis for previously answered similar questions
  - Keyword + explicit skill routing
  - SSE streaming for real-time output

Specialists:
  rug_detect      β†’ Token rug/honeypot detection
  wallet_forensics β†’ Wallet funding trail analysis
  market_intel    β†’ Market conditions & whale analysis
  bundle_detect   β†’ Coordinated trading detection
  code_audit      β†’ Smart contract vulnerability scanning
  social_sentiment β†’ Sentiment divergence analysis
  airdrop_assess  β†’ Airdrop claim safety evaluation
  defi_yield      β†’ DeFi yield trap identification
  general         β†’ Agent MUNCH default operative
"""

import contextlib
import hashlib
import json
import logging
import os
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field

logger = logging.getLogger("agent.system")

# ═══════════════════════════════════════════════════════════
# AGENT DEFINITIONS
# ═══════════════════════════════════════════════════════════


@dataclass
class AgentDef:
    id: str
    name: str
    icon: str
    description: str
    system_prompt: str
    model: str
    fallbacks: list[str] = field(default_factory=list)
    temperature: float = 0.3
    max_tokens: int = 800
    color: str = "#8B5CF6"  # UI color
    output_format: str = "standard"  # standard, evidence_chain, threat_rating
    databus_context: list[str] = field(default_factory=list)  # DataBus chains to inject


MUNCH_BASE = """You are Agent MUNCH, a crypto intelligence operative for Rug Munch Intelligence.
You are NOT a generic AI assistant. You are a highly trained specialist operative.
Speak like briefing a client β€” direct, forensic, precise. Never say "I'm an AI" or "as an AI."
Use threat classification: CRITICAL, HIGH, MEDIUM, LOW. Use confidence scores (0-100%).
Reference real data when available. If you lack data, say "I need to pull [X] data β€” recommend running [tool]."
Never fabricate addresses, prices, or on-chain data. Be skeptical. Trust nothing until verified.
"""

AGENTS = {
    "rug_detect": AgentDef(
        id="rug_detect",
        name="Rug Detection Specialist",
        icon="πŸ›‘οΈ",
        description="Token rug pull, honeypot, and scam detection specialist",
        system_prompt=MUNCH_BASE
        + """You specialize in detecting rug pulls, honeypots, and token scams.
Focus on: liquidity lock verification, mint authority analysis, deployer wallet forensics,
honeypot detection patterns, proxy contract abuse, concentrated ownership risk.
Format output as THREAT RATING: [LEVEL] (Score: X/100) followed by KEY FINDINGS and RECOMMENDATION.
When you identify a rug pattern, say "RUG PATTERN DETECTED" with specific evidence.""",
        model="nvidia/nemotron-3-super-120b-a12b:free",
        fallbacks=["google/gemma-4-31b-it:free"],
        temperature=0.2,
        color="#EF4444",
        output_format="threat_rating",
        databus_context=["alerts", "market_overview"],
    ),
    "wallet_forensics": AgentDef(
        id="wallet_forensics",
        name="Wallet Forensic Investigator",
        icon="πŸ”",
        description="Wallet funding trail analysis, entity resolution, insider network mapping",
        system_prompt=MUNCH_BASE
        + """You specialize in wallet forensics and funding trail analysis.
Focus on: wallet clustering, deployer wallet networks, mixer exit detection,
insider wallet identification, counterparty risk, funding source tracing.
Format output as CHAIN OF CUSTODY: wallet β†’ funding source β†’ linked wallets β†’ risk classification.
Classify wallets as: SMART MONEY, INSIDER, MEME DUMPER, MIXER EXIT, TEAM WALLET, MEV BOT.""",
        model="google/gemma-4-26b-a4b-it:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.2,
        color="#22D3EE",
        output_format="evidence_chain",
        databus_context=["whale_alerts", "alerts"],
    ),
    "market_intel": AgentDef(
        id="market_intel",
        name="Market Intelligence Analyst",
        icon="πŸ“Š",
        description="Market conditions, whale movements, Fear & Greed, prediction markets",
        system_prompt=MUNCH_BASE
        + """You specialize in market intelligence analysis.
Focus on: whale movement interpretation, DEX flow anomalies, volume spikes,
Fear & Greed contextualization, sentiment divergence from on-chain data,
prediction market signals, macro crypto conditions.
During Extreme Greed periods, explicitly flag elevated scam and rug risk.
Be data-driven β€” cite specific metrics, not vague observations.""",
        model="qwen/qwen3-next-80b-a3b-instruct:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.4,
        color="#8B5CF6",
        output_format="standard",
        databus_context=["market_overview", "trending", "whale_alerts"],
    ),
    "bundle_detect": AgentDef(
        id="bundle_detect",
        name="Bundle Detection Operator",
        icon="πŸ”—",
        description="Coordinated trading detection, wash trading, same-timestamp analysis",
        system_prompt=MUNCH_BASE
        + """You specialize in detecting coordinated trading bundles.
Focus on: same-timestamp transaction clusters, gas-funded wallet groups,
wash trading patterns, insider pre-positioning, coordinated buy/sell walls,
MEV sandwich attack patterns, token launch sniping detection.
Format: BUNDLE IDENTIFIED β†’ wallets involved β†’ timing β†’ estimated profit β†’ THREAT LEVEL.""",
        model="nvidia/nemotron-3-super-120b-a12b:free",
        fallbacks=["google/gemma-4-31b-it:free"],
        temperature=0.2,
        color="#F59E0B",
        output_format="evidence_chain",
        databus_context=["bundle_detect", "alerts"],
    ),
    "code_audit": AgentDef(
        id="code_audit",
        name="Multi-Chain Code Auditor",
        icon="πŸ“",
        description="Smart contract vulnerability scanning across EVM, Solana, and more",
        system_prompt=MUNCH_BASE
        + """You specialize in smart contract code auditing across multiple chains.
EVM focus: proxy upgrade abuse, unrestricted mint, hidden owner functions, reentrancy, unsafe delegatecall.
Solana focus: mint authority freeze, close authority, unchecked CPI, fake CPI returns.
Base focus: unverified contract risks, permissioned token patterns.
Format: VULNERABILITY SCORECARD listing each finding with severity (CRITICAL/HIGH/MEDIUM/LOW),
the specific code pattern, and remediation.""",
        model="nvidia/nemotron-3-super-120b-a12b:free",
        fallbacks=["google/gemma-4-31b-it:free"],
        temperature=0.2,
        color="#06D6A0",
        output_format="threat_rating",
        databus_context=["alerts"],
    ),
    "social_sentiment": AgentDef(
        id="social_sentiment",
        name="Social Sentiment Decoder",
        icon="πŸ—£οΈ",
        description="X/Twitter sentiment vs on-chain movement divergence analysis",
        system_prompt=MUNCH_BASE
        + """You specialize in social sentiment analysis and its divergence from on-chain reality.
Focus on: Twitter/X sentiment vs actual wallet behavior, pump-and-dump social patterns,
influencer wallet timing correlation, coordinated shill detection,
sentiment manipulation via bot networks, "this is fine" divergence signals.
Key insight: when sentiment says BUY but whales are EXITING, that's the classic divergence.
Format: SENTIMENT vs ON-CHAIN: divergence score, social signals, on-chain reality, ASSESSMENT.""",
        model="qwen/qwen3-next-80b-a3b-instruct:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.4,
        color="#38BDF8",
        output_format="standard",
        databus_context=["market_overview", "trending", "whale_alerts"],
    ),
    "airdrop_assess": AgentDef(
        id="airdrop_assess",
        name="Airdrop Threat Assessor",
        icon="🎁",
        description="Airdrop claim safety, signature risk, wallet drain potential evaluation",
        system_prompt=MUNCH_BASE
        + """You specialize in airdrop and claim safety assessment.
Focus on: contract verification for claims, signature requirement risks (EIP-712 phishing),
wallet drain potential in claim processes, gas spike exploitation during claims,
fake airdrop phishing detection, legitimate vs scam airdrop differentiation.
Key rule: NEVER recommend clicking a claim link without verifying the contract address on-chain.
Format: AIRDROP RATING with legitimacy score, claim safety checklist, and specific risks.""",
        model="google/gemma-4-31b-it:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.3,
        color="#A78BFA",
        output_format="threat_rating",
        databus_context=["alerts", "market_overview"],
    ),
    "defi_yield": AgentDef(
        id="defi_yield",
        name="DeFi Yield Trap Detector",
        icon="πŸ“ˆ",
        description="Unsustainable yield detection, emission inflation, TVL manipulation",
        system_prompt=MUNCH_BASE
        + """You specialize in detecting unsustainable DeFi yield mechanisms.
Focus on: emission schedule inflation analysis, TVL manipulation via protocol-owned liquidity,
reward token devaluation trajectories, hidden lock periods and withdrawal gates,
yield farming that requires depositing into unverified contracts,
leveraged yield loops that amplify risk.
Key pattern: if yield >30% APY with no clear revenue source, it's likely a yield trap.
Format: YIELD SAFETY SCORE with sustainability analysis, risk factors, and honest yield estimate.""",
        model="qwen/qwen3-next-80b-a3b-instruct:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.3,
        color="#FB3B76",
        output_format="threat_rating",
        databus_context=["market_overview", "trending"],
    ),
    "general": AgentDef(
        id="general",
        name="Agent MUNCH",
        icon="πŸ•΅οΈ",
        description="General crypto intelligence operative β€” your all-purpose specialist",
        system_prompt=MUNCH_BASE
        + """You are the default operative, skilled in all areas of crypto intelligence.
You can discuss token security, wallet analysis, market conditions, DeFi risks,
blockchain technology, trading strategies, and scam patterns with equal expertise.
When a question falls outside your expertise, say "This requires [specialist name] deployment β€”
I recommend switching to that skill for deeper analysis."
Always offer actionable next steps: "Recommend running [tool] at rugmunch.io for [specific analysis].""",
        model="google/gemma-4-31b-it:free",
        fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"],
        temperature=0.5,
        color="#8B5CF6",
        output_format="standard",
        databus_context=["market_overview", "alerts"],
    ),
}

# ═══════════════════════════════════════════════════════════
# ROUTING
# ═══════════════════════════════════════════════════════════

ROUTES = {
    "rug_detect": [
        "scan",
        "token",
        "scam",
        "rug",
        "honeypot",
        "contract",
        "audit",
        "safety",
        "risk score",
        "verify token",
        "check coin",
        "rug pull",
        "is this safe",
        "is this a scam",
    ],
    "wallet_forensics": [
        "wallet",
        "address",
        "holder",
        "whale",
        "smart money",
        "portfolio",
        "entity",
        "counterparty",
        "deployer",
        "funding",
        "trace",
        "follow the money",
        "cluster",
    ],
    "market_intel": [
        "market",
        "trending",
        "fear greed",
        "sentiment",
        "prediction",
        "price",
        "volume",
        "mover",
        "gainer",
        "condition",
        "macro",
        "btc",
        "eth",
        "sol",
        "dominance",
    ],
    "bundle_detect": [
        "bundle",
        "coordinated",
        "wash trade",
        "same time",
        "sniper",
        "launch",
        "front run",
        "sandwich",
        "mev",
        "bot cluster",
    ],
    "code_audit": [
        "code",
        "contract",
        "source",
        "audit",
        "vulnerability",
        "proxy",
        "mint authority",
        "reentrancy",
        "delegatecall",
        "verify source",
        "solana program",
    ],
    "social_sentiment": [
        "twitter",
        "social",
        "sentiment",
        "influencer",
        "shill",
        "hype",
        "pump social",
        "bot network",
        "community sentiment",
        "reddit",
    ],
    "airdrop_assess": [
        "airdrop",
        "claim",
        "free token",
        "signature",
        "eip-712",
        "phishing claim",
        "eligible",
        "merkle",
    ],
    "defi_yield": [
        "yield",
        "apy",
        "farming",
        "liquidity pool",
        "staking",
        "emission",
        "tvl",
        "protocol",
        "curve",
        "convex",
        "leveraged",
    ],
}


def classify(msg: str) -> str:
    m = msg.lower()
    for agent_id, keywords in ROUTES.items():
        if any(kw in m for kw in keywords):
            return agent_id
    return "general"


# ═══════════════════════════════════════════════════════════
# RAG CONTEXT INJECTION
# ═══════════════════════════════════════════════════════════


async def fetch_databus_context(chains: list[str]) -> str:
    """Fetch real data from DataBus and format as context for the LLM."""
    if not chains:
        return ""

    context_parts = []
    try:
        import httpx

        for chain in chains:
            try:
                url = "http://localhost:8000/api/v1/databus/fetch"
                async with httpx.AsyncClient(timeout=8) as c:
                    r = await c.post(url, json={"data_type": chain, "limit": 5})
                    if r.status_code == 200:
                        data = r.json()
                        # Extract the actual data payload
                        result = data.get("data", data.get("results", [{}]))
                        if isinstance(result, list) and result:
                            result = result[0].get("data", result[0]) if result else {}
                        context_parts.append(f"[{chain} DATA]: {json.dumps(result, default=str)[:800]}")
            except Exception as e:
                logger.warning(f"DataBus context fetch failed for {chain}: {e}")
    except Exception as e:
        logger.warning(f"DataBus context system unavailable: {e}")

    if context_parts:
        return "\n\nREAL-TIME PLATFORM DATA (use this in your analysis, do not fabricate):\n" + "\n".join(context_parts)
    return ""


# ═══════════════════════════════════════════════════════════
# SMART CACHING
# ═══════════════════════════════════════════════════════════


async def check_cache(msg: str, agent_id: str) -> str | None:
    """Check Redis for previously answered similar questions."""
    try:
        import redis

        r = redis.Redis(
            host=os.getenv("REDIS_HOST", "localhost"),
            port=int(os.getenv("REDIS_PORT", "6379")),
            password=os.getenv("REDIS_PASSWORD", ""),
            decode_responses=True,
            socket_timeout=2,
        )
        # Hash the question + agent for cache key
        cache_key = f"agent_cache:{agent_id}:{hashlib.sha256(msg.encode()).hexdigest()[:16]}"
        cached = r.get(cache_key)
        if cached:
            logger.info(f"Cache hit for {agent_id}: {cache_key}")
            return cached
    except Exception:
        pass
    return None


async def store_cache(msg: str, agent_id: str, response: str, ttl: int = 3600):
    """Store response in Redis cache. TTL defaults to 1 hour."""
    try:
        import redis

        r = redis.Redis(
            host=os.getenv("REDIS_HOST", "localhost"),
            port=int(os.getenv("REDIS_PORT", "6379")),
            password=os.getenv("REDIS_PASSWORD", ""),
            decode_responses=True,
            socket_timeout=2,
        )
        cache_key = f"agent_cache:{agent_id}:{hashlib.sha256(msg.encode()).hexdigest()[:16]}"
        # Only cache if response is substantive (>200 chars)
        if len(response) > 200:
            r.setex(cache_key, ttl, response[:4000])  # Cap stored size
    except Exception:
        pass


# ═══════════════════════════════════════════════════════════
# STREAMING ROUTER
# ═══════════════════════════════════════════════════════════


async def route_and_stream(msg: str, role_hint: str = "") -> AsyncGenerator[dict, None]:
    """Route to specialist agent, inject RAG context, stream response.

    Provider priority:
    1. Gemini 2.5 Flash (FREE, 1500 RPD, smart, fast)
    2. OpenRouter free models (fallback when Gemini rate-limited)
    """
    import httpx

    agent_id = role_hint if role_hint in AGENTS else classify(msg)
    agent = AGENTS[agent_id]

    yield {
        "type": "agent",
        "role": agent_id,
        "name": agent.name,
        "icon": agent.icon,
        "color": agent.color,
    }

    # Check cache first -- skip LLM call entirely if we already have the answer
    cached = await check_cache(msg, agent_id)
    if cached:
        yield {"type": "cache_hit", "agent": agent_id}
        yield {"type": "token", "text": cached}
        yield {"type": "done"}
        return

    # Fetch RAG context from DataBus
    rag_context = await fetch_databus_context(agent.databus_context)
    system_with_context = agent.system_prompt + rag_context
    messages = [
        {"role": "system", "content": system_with_context},
        {"role": "user", "content": msg},
    ]

    full_response = ""

    # ── Provider 1: Gemini (FREE, primary) ──
    from dotenv import load_dotenv

    load_dotenv()
    gemini_keys = []
    for env_var in ["GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3"]:
        k = os.environ.get(env_var, "")
        if k and len(k) > 20:
            gemini_keys.append(k)

    for gkey in gemini_keys:
        try:
            # Gemini native streaming API (key in URL, OpenAI-compatible format)
            base_url = f"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions?key={gkey}"
            headers = {"Content-Type": "application/json"}
            body = {
                "model": "gemini-2.5-flash",
                "messages": messages,
                "max_tokens": agent.max_tokens,
                "temperature": agent.temperature,
                "stream": True,
            }

            async with httpx.AsyncClient(timeout=45) as c:
                async with c.stream("POST", base_url, json=body, headers=headers) as r:
                    if r.status_code == 200:
                        async for line in r.aiter_lines():
                            if line.startswith("data: "):
                                d = line[6:]
                                if d == "[DONE]":
                                    if full_response:
                                        await store_cache(msg, agent_id, full_response)
                                    yield {"type": "done"}
                                    return
                                try:
                                    ch = json.loads(d)
                                    txt = ch.get("choices", [{}])[0].get("delta", {}).get("content", "")
                                    if txt:
                                        full_response += txt
                                        yield {"type": "token", "text": txt}
                                except Exception:
                                    pass
                        if full_response:
                            await store_cache(msg, agent_id, full_response)
                        yield {"type": "done"}
                        return
                    elif r.status_code == 429:
                        logger.info("Gemini rate-limited, trying next key/fallback")
                        continue  # Try next key or fallback provider
                    else:
                        logger.warning(f"Gemini error {r.status_code}, trying fallback")
                        continue
        except Exception as e:
            logger.warning(f"Gemini call failed: {e}")
            continue

    # ── Provider 2: OpenRouter (fallback, costs credits) ──
    api_key = os.environ.get("OPENROUTER_API_KEY", "")
    if not api_key:
        b64 = os.environ.get("LLM_API_KEY_B64", "")
        if b64:
            import base64

            with contextlib.suppress(BaseException):
                api_key = base64.b64decode(b64).decode()

    if api_key:
        models = [agent.model, *agent.fallbacks]
        for model in models:
            try:
                headers = {
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                    "HTTP-Referer": "https://rugmunch.io",
                    "X-Title": f"RMI {agent.name}",
                }
                body = {
                    "model": model,
                    "messages": messages,
                    "max_tokens": agent.max_tokens,
                    "temperature": agent.temperature,
                    "stream": True,
                }

                async with httpx.AsyncClient(timeout=60) as c, c.stream(
                    "POST",
                    "https://openrouter.ai/api/v1/chat/completions",
                    json=body,
                    headers=headers,
                ) as r:
                    if r.status_code == 200:
                        async for line in r.aiter_lines():
                            if line.startswith("data: "):
                                d = line[6:]
                                if d == "[DONE]":
                                    if full_response:
                                        await store_cache(msg, agent_id, full_response)
                                    yield {"type": "done"}
                                    return
                                try:
                                    ch = json.loads(d)
                                    txt = ch.get("choices", [{}])[0].get("delta", {}).get("content", "")
                                    if txt:
                                        full_response += txt
                                        yield {"type": "token", "text": txt}
                                except Exception:
                                    pass
                        if full_response:
                            await store_cache(msg, agent_id, full_response)
                        yield {"type": "done"}
                        return
                    elif r.status_code == 429:
                        continue
            except Exception as e:
                logger.warning(f"OpenRouter model {model} failed: {e}")
                continue

    yield {
        "type": "error",
        "text": "All providers unavailable (Gemini rate-limited, OpenRouter failed)",
    }
    yield {"type": "done"}


def agents_list() -> list:
    return [
        {
            "id": a.id,
            "name": a.name,
            "icon": a.icon,
            "model": a.model,
            "description": a.description,
            "color": a.color,
            "output_format": a.output_format,
        }
        for a in AGENTS.values()
    ]