XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
from __future__ import annotations

import os
import time
import base64
import requests
import asyncio
from datetime import date
from concurrent.futures import ThreadPoolExecutor, as_completed

from .template import OpenaiTemplate
from ..typing import AsyncResult, Messages
from ..errors import BadRequestError
from .. import debug

# ---------------------------------------------------------------------------
# Seed list & Cache — known open Ollama servers
# 
# [!] PRO TIP: If these seed servers ever become slow or dead (since they are 
# public/home nodes), you can easily find 100+ fresh ones using our built-in tool:
# Run: python etc/tool/fofa_ollama_parser.py --help
# ---------------------------------------------------------------------------
import json
from pathlib import Path
from ..cookies import get_cookies_dir


#     setInterval(()=>{
#         window.urls = window.urls || JSON.parse(localStorage.getItem('urls')) || [];
#         document.querySelectorAll('.hsxa-host a').forEach(e=>{
#             if (!window.urls.includes(e.href)) {
#                 window.urls.push(e.href);
#             }
#         })
#         localStorage.setItem('urls', JSON.stringify(window.urls));
#         console.log(window.urls.length);
#     }, 1000);

_DEFAULT_SEED_SERVERS = [
  "http://116.202.111.94:11434",
  "http://130.89.48.109:11434",
  "http://85.214.43.150:11434",
  "http://130.89.48.109:11434",
  "http://213.132.219.17:11434",
  "http://155.133.208.195:11434",
  "http://46.17.99.157:11434",
  "http://193.237.205.200:11434",
  "http://51.17.50.106:2052",
  "http://46.224.147.115:11434",
  "http://54.215.114.112:7547",
  "http://43.210.64.106:7547",
  "http://87.208.240.33:11434",
  "http://64.176.39.95:11434",
  "http://46.224.156.158:11434",
  "http://185.100.232.224:11434",
  "http://38.76.189.45:11434",
  "http://38.76.189.41:11434",
  "http://116.203.177.162:11434",
  "http://204.168.244.123:11434",
  "http://195.201.234.76:11434",
  "http://194.62.157.184:11434",
  "http://185.45.193.80:11434",
  "http://199.79.202.22:11434",
  "http://78.46.41.183:11434",
  "http://1.243.43.248:11434",
  "http://43.210.64.106:7547",
  "http://38.76.189.74:11434",
  "http://213.136.76.182:11434",
  "http://89.58.3.79:11434",
  "http://116.203.112.201:11434",
  "http://100.30.6.43:11434",
  "http://146.0.72.136:11434",
  "http://204.168.244.123:11434",
  "http://44.244.46.70:7547",
  "http://193.237.153.60:11434",
  "http://79.137.197.6:11434",
  "http://220.249.186.40:11434",
  "http://103.235.75.117:11434",
  "http://202.141.161.50:11434",
  "http://66.94.124.143:11434",
  "http://94.141.160.99:11434",
  "http://38.180.104.127:11434",
  "http://147.93.183.134:11434",
  "http://82.135.28.45:11434",
  "http://152.53.93.215:11434",
  "http://45.145.42.104:11434",
  "http://203.176.113.216:11434",
  "http://85.214.43.150:11434",
  "http://133.4.188.2:11434",
  "http://116.202.197.155:11434",
  "http://57.128.123.135:11434",
  "http://82.135.28.45:11434",
  "http://63.177.73.22:7547",
  "http://211.23.87.144:11434",
  "http://204.168.139.0:11434",
  "http://16.26.230.113:7547",
  "http://46.224.83.114:11434",
  "http://20.246.91.177:11434",
  "http://116.203.112.201:11434",
  "http://168.235.74.31:11434",
  "http://84.22.103.64:11434",
  "http://223.113.66.126:11434",
  "http://38.76.189.19:11434",
  "http://194.62.157.184:11434",
  "http://81.4.125.240:11434",
  "http://209.97.173.219:11434",
  "http://163.172.212.132:11434",
  "http://116.202.111.94:11434",
  "http://18.223.75.148:11434",
  "http://213.132.219.17:11434",
  "http://139.129.25.182:11434",
  "http://62.45.168.106:11434",
  "http://46.4.216.118:11434",
  "http://117.55.199.23:11434",
  "http://31.172.78.56:11434",
  "http://62.171.155.8:11434",
  "http://49.13.48.26:11434",
  "http://82.165.174.61:11434",
  "http://116.203.53.120:11434",
  "http://31.172.78.56:11434",
  "http://108.160.206.30:11434",
  "http://116.203.219.128:11434",
  "http://161.153.32.111:11434",
  "http://217.174.245.24:11434",
  "http://77.239.123.2:11434",
  "http://18.223.75.148:11434",
  "http://89.58.3.79:11434",
  "http://5.75.180.13:11434",
  "http://34.31.140.94:11434",
  "http://204.168.198.89:11434",
  "http://45.87.137.100:11434",
  "http://107.175.125.166:11434",
  "http://20.246.91.177:11434",
  "http://185.45.193.80:11434",
  "http://204.168.139.0:11434",
  "http://178.105.145.53:11434",
  "http://64.156.70.180:11434",
  "http://165.1.76.13:11434",
  "http://62.45.168.106:11434",
  "http://158.101.214.195:11434",
  "http://5.9.1.80:11434",
  "http://82.165.174.61:11434",
  "http://63.177.73.22:7547",
  "http://77.239.123.2:11434",
  "http://51.178.49.219:11434",
  "http://116.203.198.188:11434",
  "http://38.76.189.18:11434",
  "http://216.70.69.75:11434",
  "http://88.168.52.207:11434",
  "http://62.238.14.177:11434",
  "http://142.132.252.21:11434",
  "http://38.76.189.9:11434",
  "http://155.133.208.195:11434",
  "http://167.71.147.184:11434",
  "http://35.221.126.180:11434",
  "http://63.179.110.87:2082",
  "http://3.67.10.231:8080",
  "http://199.79.202.22:11434",
  "http://178.104.205.2:11434",
  "http://116.203.177.162:11434",
  "http://165.1.78.194:11434",
  "http://3.67.10.231:8080",
  "http://5.129.226.192:11434",
  "http://198.206.133.250:11434",
  "http://178.254.28.95:11434",
  "http://146.0.72.136:11434",
  "http://16.63.120.43:7547",
  "http://204.168.196.150:11434",
  "http://46.224.186.78:11434",
  "http://136.243.60.49:11434",
  "http://116.202.9.89:11434",
  "http://46.224.186.78:11434",
  "http://116.203.219.128:11434",
  "http://38.76.189.31:11434",
  "http://75.128.229.121:11434",
  "http://16.26.230.113:7547",
  "http://52.201.213.145:11434",
  "http://5.78.200.46:11434",
  "http://2.59.170.202:11434",
  "http://185.191.127.178:11434",
  "http://81.131.169.17:11434",
  "http://178.105.145.53:11434",
  "http://116.203.212.217:11434",
  "http://54.215.114.112:7547",
  "http://178.104.197.254:11434",
  "http://116.203.212.217:11434",
  "http://5.9.1.80:11434",
  "http://18.61.29.191:7547",
  "http://63.179.110.87:2082",
  "http://54.36.111.107:11434",
  "http://103.66.120.232:11434",
  "http://27.92.231.18:11434",
  "http://79.137.197.6:11434",
  "http://51.17.50.106:2052",
  "http://178.105.62.143:11434",
  "http://101.111.228.63:11434",
  "http://150.230.164.69:11434",
  "http://85.214.44.11:11434",
  "http://178.104.163.52:11434",
  "http://45.145.42.104:11434",
  "http://34.16.62.196:11434",
  "http://49.13.102.77:11434",
  "http://195.201.234.76:11434",
  "http://38.76.189.21:11434",
  "http://145.239.207.5:11434",
  "http://100.30.6.43:11434",
  "http://38.76.189.97:11434",
  "http://45.139.77.246:11434",
  "http://45.154.87.43:11434",
  "http://49.13.48.26:11434",
  "http://64.188.91.237:11434",
  "http://38.76.189.18:11434",
  "http://178.105.147.204:11434",
  "http://77.68.10.64:11434",
  "http://52.201.213.145:11434",
  "http://125.138.77.111:11434",
  "http://223.85.216.230:11434",
  "http://57.128.64.100:11434",
  "http://178.219.166.81:2082",
  "http://37.59.98.74:11434",
  "http://87.208.240.33:11434",
  "http://71.251.218.102:11434",
  "http://165.1.78.194:11434",
  "http://178.105.147.204:11434",
  "http://210.59.176.82:11434",
  "http://34.31.140.94:11434",
  "http://84.22.103.64:11434",
  "http://223.113.254.84:11434",
  "http://113.44.194.208:11434",
  "http://3.237.237.228:2077",
  "http://44.244.46.70:7547",
  "http://13.140.143.210:11434",
  "http://64.188.91.237:11434",
  "http://38.76.189.97:11434",
  "http://62.171.155.8:11434",
  "http://204.168.149.141:11434",
  "http://51.254.134.96:11434",
  "http://109.86.166.86:11434",
  "http://178.105.66.185:11434",
  "http://104.54.238.64:11434",
  "http://116.202.66.86:11434",
  "http://129.80.194.194:11434",
  "http://45.140.140.26:11434",
  "http://45.140.140.26:11434",
  "http://199.204.135.71:11434",
  "http://135.237.98.245:11434",
  "http://23.95.148.22:11434",
  "http://5.75.180.13:11434",
  "http://78.13.53.95:2077",
  "http://31.70.86.211:11434",
  "http://158.101.214.195:11434",
  "http://13.140.143.210:11434",
  "http://136.243.60.49:11434",
  "http://83.86.59.188:11434",
  "http://198.206.133.250:11434",
  "http://69.243.159.16:11434",
  "http://51.158.152.190:11434",
  "http://178.105.62.143:11434",
  "http://34.16.62.196:11434",
  "http://204.168.198.89:11434",
  "http://116.202.66.86:11434",
  "http://78.13.53.95:2077",
  "http://167.86.113.188:11434",
  "http://51.77.188.225:11434",
  "http://90.149.239.71:11434",
  "http://51.254.130.116:11434",
  "http://64.156.70.180:11434",
  "http://180.110.147.114:11434",
  "http://47.79.39.175:11434",
  "http://20.107.59.198:11434",
  "http://16.63.120.43:7547",
  "http://114.34.180.200:11434",
  "http://84.86.220.240:11434",
  "http://38.76.189.45:11434",
  "http://204.168.175.197:11434",
  "http://129.80.194.194:11434",
  "http://2.59.170.202:11434",
  "http://116.202.197.155:11434",
  "http://78.46.41.183:11434",
  "http://18.61.29.191:7547",
  "http://46.224.156.158:11434",
  "http://204.168.196.150:11434",
  "http://62.68.75.4:11434",
  "http://161.153.32.111:11434",
  "http://152.53.251.120:11434",
  "http://204.168.149.141:11434",
  "http://129.80.43.33:11434",
  "http://207.148.68.227:11434",
  "http://117.50.171.144:11434",
  "http://46.243.3.122:11434",
  "http://220.135.48.55:11434",
  "http://18.136.206.156:11434",
  "http://58.127.230.165:11434",
  "http://45.139.77.246:11434",
  "http://188.166.254.32:11434",
  "http://178.104.197.254:11434",
  "http://201.137.77.153:11434",
  "http://135.237.98.245:11434",
  "http://107.175.125.166:11434",
  "http://38.76.189.41:11434",
  "http://23.95.148.22:11434",
  "http://103.137.250.43:11434",
  "http://204.168.254.120:11434",
  "http://185.100.232.224:11434",
  "http://150.136.60.84:11434",
  "http://220.134.52.221:11434",
  "http://66.94.124.143:11434",
  "http://204.168.254.120:11434",
  "http://163.172.212.132:11434",
  "http://38.76.189.74:11434",
  "http://45.154.87.43:11434",
  "http://54.93.197.63:7547",
  "http://217.182.133.168:11434",
  "http://35.208.40.227:11434",
  "http://129.80.43.33:11434",
  "http://84.86.220.240:11434",
  "http://165.1.76.13:11434",
  "http://38.76.189.19:11434",
  "http://116.203.198.188:11434",
  "http://116.202.9.89:11434",
  "http://46.4.216.118:11434",
  "http://152.53.251.120:11434",
  "http://178.105.66.185:11434",
  "http://142.132.252.21:11434",
  "http://103.235.75.117:11434",
  "http://85.214.44.11:11434",
  "http://46.243.3.122:11434",
  "http://185.191.127.178:11434",
  "http://147.93.139.24:11434",
  "http://113.44.194.208:11434",
  "http://217.182.67.5:11434",
  "http://81.4.125.240:11434",
  "http://62.68.75.4:11434",
  "http://5.101.168.158:11434",
  "http://117.50.171.144:11434",
  "http://147.93.139.24:11434",
  "http://147.93.183.23:11434",
  "http://54.36.111.107:11434",
  "http://46.224.203.89:11434",
  "http://103.137.250.43:11434",
  "http://116.203.53.120:11434",
  "http://87.98.145.87:11434",
  "http://24.236.158.179:11434",
  "http://64.225.38.49:11434",
  "http://62.238.14.177:11434",
  "http://216.70.69.75:11434",
  "http://79.157.228.102:11434",
  "http://185.237.206.241:11434",
  "http://13.140.25.193:11434",
  "http://46.224.147.115:11434",
  "http://152.53.93.215:11434",
  "http://178.219.166.81:2082",
  "http://69.243.159.16:11434",
  "http://75.128.229.121:11434",
  "http://38.76.189.31:11434",
  "http://5.129.226.192:11434",
  "http://117.55.199.23:11434",
  "http://51.158.152.190:11434",
  "http://20.107.59.198:11434",
  "http://158.69.27.163:11434",
  "http://185.237.206.241:11434",
  "http://46.17.99.157:11434",
  "http://125.227.28.166:11434",
  "http://147.93.183.23:11434",
  "http://54.93.197.63:7547",
  "http://3.237.237.228:2077",
  "http://151.80.21.134:11434",
  "http://147.93.183.134:11434",
  "http://178.104.163.52:11434",
  "http://38.76.189.21:11434",
  "http://46.224.203.89:11434",
  "http://1.255.85.149:11434",
  "http://204.168.175.197:11434",
  "http://31.70.78.250:11434",
  "http://35.208.40.227:11434",
  "http://104.54.238.64:11434",
  "http://38.76.189.9:11434",
  "http://160.16.60.183:11434",
  "http://64.176.229.210:11434"
]

def get_cache_file() -> Path:
    return Path(get_cookies_dir()) / "ollama_servers.json"

def _get_cached_servers() -> list[str]:
    servers = set(_DEFAULT_SEED_SERVERS)
    try:
        cache_file = get_cache_file()
        if cache_file.exists():
            with open(cache_file, "r") as f:
                data = json.load(f)
                if isinstance(data, list):
                    servers.update(data)
    except Exception as e:
        debug.error(f"OllamaSwarm: failed to read cache: {e}")
    return list(servers)

# ---------------------------------------------------------------------------
# FOFA discovery — free public API
# ---------------------------------------------------------------------------
_FOFA_API = "https://fofa.info/api/v1/search/all"
_FOFA_QUERY = 'port="11434" && body="Ollama"'
_FOFA_FIELDS = "ip,port"


def _fofa_discover(max_results: int = 50) -> list[str]:
    """Fetch Ollama endpoints from FOFA public API (requires FOFA_EMAIL + FOFA_KEY).

    Returns list of 'http://ip:port' strings. Returns empty list on failure.
    """
    email = os.environ.get("FOFA_EMAIL", "")
    key = os.environ.get("FOFA_KEY", "")
    if not email or not key:
        return []

    try:
        qbase64 = base64.b64encode(_FOFA_QUERY.encode()).decode()
        params = {
            "email": email,
            "key": key,
            "qbase64": qbase64,
            "page": 1,
            "size": min(max_results, 100),
            "fields": _FOFA_FIELDS,
        }
        resp = requests.get(_FOFA_API, params=params, timeout=15)
        resp.raise_for_status()
        data = resp.json()

        if data.get("error"):
            debug.error(f"OllamaSwarm FOFA: {data.get('errmsg', 'unknown error')}")
            return []

        results = data.get("results", [])
        endpoints = []
        for item in results:
            if isinstance(item, list) and len(item) >= 2:
                ip, port = item[0], item[1]
                endpoints.append(f"http://{ip}:{port}")
            elif isinstance(item, dict):
                ip = item.get("ip", "")
                port = item.get("port", 11434)
                if ip:
                    endpoints.append(f"http://{ip}:{port}")

        debug.log(f"OllamaSwarm: FOFA returned {len(endpoints)} endpoints")
        return endpoints

    except Exception as exc:
        debug.error(f"OllamaSwarm FOFA discovery failed: {exc}")
        return []


# ---------------------------------------------------------------------------
# Server validation
# ---------------------------------------------------------------------------
_PROBE_TIMEOUT = 5
_PROBE_WORKERS = 20


def _probe_server(url: str) -> tuple[str, list[str]] | None:
    """Probe a single Ollama server. Returns (url, [model_names]) or None."""
    try:
        resp = requests.get(f"{url}/api/tags", timeout=_PROBE_TIMEOUT)
        resp.raise_for_status()
        models = [
            m.get("name", "")
            for m in resp.json().get("models", [])
            if "/attacker/" not in m.get("name", "")
            and not m.get("name", "").startswith("model-b")
        ]
        if models:
            return url, models
    except Exception:
        pass
    return None


def _discover_servers() -> dict[str, list[str]]:
    """Discover alive Ollama servers and their models.

    Returns dict: {server_url: [model1, model2, ...]}
    """
    models_file =  Path(get_cookies_dir()) / "models" / date.today().strftime("%Y-%m-%d") / "ollama-swarm.json"
    if models_file.exists():
        try:
            with open(models_file, "r") as f:
                data = json.load(f)
                if isinstance(data, dict):
                    debug.log(f"OllamaSwarm: loaded {len(data)} servers from today's cache")
                    return data
        except Exception as e:
            debug.error(f"OllamaSwarm: failed to read today's models cache: {e}")
    # Collect candidate URLs
    candidates = _get_cached_servers()

    # Try FOFA discovery
    fofa_results = _fofa_discover()
    for url in fofa_results:
        if url not in candidates:
            candidates.append(url)

    debug.log(f"OllamaSwarm: probing {len(candidates)} candidate servers ...")

    # Validate concurrently
    alive: dict[str, list[str]] = {}
    with ThreadPoolExecutor(max_workers=_PROBE_WORKERS) as pool:
        futures = {pool.submit(_probe_server, url): url for url in candidates}
        for fut in as_completed(futures):
            result = fut.result()
            if result is not None:
                url, models = result
                alive[url] = models

    debug.log(f"OllamaSwarm: {len(alive)} servers alive with models")
    
    # Save newly discovered alive servers to cache
    if alive:
        try:
            models_file.parent.mkdir(parents=True, exist_ok=True)
            with open(models_file, "w") as f:
                json.dump(alive, f)
        except Exception as e:
            debug.error(f"OllamaSwarm: failed to save today's models cache: {e}")
        
    return alive


# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
_CACHE_TTL = 3600  # re-discover every hour


class OllamaSwarm(OpenaiTemplate):
    label = "Ollama Swarm 🐝"
    url = "https://ollama.com"
    needs_auth = False
    working = True
    active_by_default = True
    default_model = "qwen3:14b"
    sort_models = True

    # Maps model name -> list of server base URLs
    model_to_servers: dict[str, list[str]] = {}
    _cache_time: float = 0

    @classmethod
    def get_models(cls, api_key: str = None, base_url: str = None, **kwargs) -> list[str]:
        now = time.time()

        # Return cached models if still valid
        if cls.models and (now - cls._cache_time) < _CACHE_TTL:
            return cls.models

        # Discover servers
        alive = _discover_servers()

        if not alive:
            # If nothing found and we had a cache, keep using it
            if cls.models:
                debug.log("OllamaSwarm: no servers found, using cached models")
                return cls.models
            return []

        # Build model -> servers mapping
        cls.model_to_servers = {}
        for server_url, models in alive.items():
            for name in models:
                if name not in cls.model_to_servers:
                    cls.model_to_servers[name] = []
                cls.model_to_servers[name].append(server_url)
                cls.models_count[name] = cls.models_count.get(name, 0) + 1
        cls.models_count = dict(sorted(cls.models_count.items(), key=lambda item: item[1], reverse=True))
        cls.models = list(cls.models_count.keys())

        if cls.models:
            cls.live += 1

        if cls.default_model not in cls.models and cls.models:
            cls.default_model = cls.models[0]

        cls._cache_time = now
        debug.log(f"OllamaSwarm: {len(cls.models)} models from {len(alive)} servers")
        return cls.models

    @classmethod
    async def get_quota(cls, api_key: str = None, **kwargs) -> dict:
        return {}

    @classmethod
    async def create_async_generator(
        cls,
        model: str,
        messages: Messages,
        api_key: str = None,
        base_url: str = None,
        **kwargs,
    ) -> AsyncResult:
        if not cls.models:
            cls.get_models()

        server_urls = cls.model_to_servers.get(model)
        if server_urls is None:
            resolved = cls.get_model(model)
            server_urls = cls.model_to_servers.get(resolved)
            if server_urls is not None:
                model = resolved

        if server_urls is None:
            raise ValueError(
                f"OllamaSwarm: model '{model}' not found on any server. "
                f"Available: {list(cls.model_to_servers.keys())[:10]}"
            )

        last_error = None
        first_chunk = None
        for server_url in server_urls:
            base_url = f"{server_url}/v1"
            debug.log(f"OllamaSwarm: trying server {server_url} for model {model}")
            try:
                gen = super().create_async_generator(
                    model,
                    messages,
                    api_key=api_key,
                    base_url=base_url,
                    yield_request=False,
                    **kwargs,
                )
                
                # TTFT Timeout: Wait max 10 seconds for the first token chunk
                try:
                    first_chunk = await asyncio.wait_for(gen.__anext__(), timeout=10.0)
                    if first_chunk:
                        yield first_chunk
                except StopAsyncIteration:
                    pass
                except asyncio.TimeoutError:
                    raise Exception("TTFT Timeout: Model took too long to start generating (>10s)")

                # If first chunk succeeded, stream the rest without strict chunk timeout
                async for chunk in gen:
                    yield chunk

                cls.model_to_servers[model] = [server_url] + [s for s in cls.model_to_servers[model] if s != server_url]
                # If we get here, generation succeeded on this server
                return
            except BadRequestError as e:
                raise e  # BadRequestError means the server is alive but model is missing/invalid, no need to try other servers
            except Exception as e:
                debug.error(f"OllamaSwarm: server {server_url} failed with error: {e}")
                last_error = e
                # If it failed AFTER yielding some chunks, we can't seamlessly switch
                # to a new server, because the output would be broken/duplicated.
                # However, with our explicit TTFT check, any failure in first chunk is safely caught!
                # Wait, how to know if we yielded? We yielded `first_chunk`.
                # If we threw an error DURING the first chunk fetch, we never yielded, so we can retry!
                # If it threw inside the subsequent `async for`, we already yielded, so we must raise.
                if first_chunk:
                    raise e
                # Otherwise, it failed before yielding anything, so try the next server!
                continue
        
        # If all servers failed before yielding any chunks
        if last_error:
            raise last_error