From 5ff9cc4587b7e23f2e2bf9dcb817ad3a4620771b Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 22 May 2026 22:31:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0openai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- onchain_scout_gate/app/config.py | 6 +- onchain_scout_gate/app/gemma_client.py | 183 ++++++++++++++---- onchain_scout_gate/app/web.py | 2 + onchain_scout_gate/config.example.yaml | 9 +- .../docs/Gemma-Ollama网关配置.md | 44 +++++ 5 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 onchain_scout_gate/docs/Gemma-Ollama网关配置.md diff --git a/onchain_scout_gate/app/config.py b/onchain_scout_gate/app/config.py index ae867cc..d7e7b5e 100644 --- a/onchain_scout_gate/app/config.py +++ b/onchain_scout_gate/app/config.py @@ -119,12 +119,14 @@ class MonitorConfig(BaseModel): class GemmaConfig(BaseModel): """ - 本地 Ollama 跑 Gemma(或其它模型)做漏斗二次分拣。 - 需在机器上自行启动 ollama 并拉取模型;开启后仅对本轮 5m 扫描命中的 WATCH/TRIGGER 按成交额取前 N 再请求。 + Gemma 漏斗:默认直连本机 Ollama(/api/chat)。 + 若使用 OpenAI 兼容网关(如 https://op.bz121.com/v1 + Bearer),设 api_style=openai 并填写 api_key。 """ enabled: bool = False ollama_base_url: str = "http://127.0.0.1:11434" + api_key: str = "" + api_style: Literal["ollama", "openai"] = "ollama" model: str = "gemma2:2b" timeout_seconds: float = 180.0 temperature: float = 0.15 diff --git a/onchain_scout_gate/app/gemma_client.py b/onchain_scout_gate/app/gemma_client.py index 72c66fd..97de314 100644 --- a/onchain_scout_gate/app/gemma_client.py +++ b/onchain_scout_gate/app/gemma_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import logging import re -from typing import Any +from typing import Any, Literal import httpx @@ -24,11 +24,108 @@ def _extract_json_object(text: str) -> dict[str, Any] | None: return None +def _resolve_api_style(conf: GemmaConfig) -> Literal["ollama", "openai"]: + style = (conf.api_style or "ollama").strip().lower() + if style == "openai": + return "openai" + if style == "ollama": + return "ollama" + if (conf.api_key or "").strip(): + return "openai" + return "ollama" + + +def _chat_url(conf: GemmaConfig, style: Literal["ollama", "openai"]) -> str: + base = conf.ollama_base_url.rstrip("/") + if style == "openai": + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + return f"{base}/api/chat" + + +def _build_headers(conf: GemmaConfig, style: Literal["ollama", "openai"]) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if style == "openai": + key = (conf.api_key or "").strip() + if key: + headers["Authorization"] = f"Bearer {key}" + return headers + + +def _strip_b64_prefix(raw: str) -> str: + s = raw.strip() + if "," in s and s.lower().startswith("data:"): + return s.split(",", 1)[1] + return s + + class OllamaGemmaClient: def __init__(self, conf: GemmaConfig) -> None: self.conf = conf + self._style = _resolve_api_style(conf) self.timeout = httpx.Timeout(conf.timeout_seconds, read=conf.timeout_seconds + 30.0) + async def _chat( + self, + *, + system: str, + user_text: str, + image_base64: str | None, + temperature: float, + json_mode: bool, + ) -> str: + url = _chat_url(self.conf, self._style) + headers = _build_headers(self.conf, self._style) + + if self._style == "openai": + user_content: str | list[dict[str, Any]] = user_text + if image_base64 and self.conf.send_chart_image: + b64 = _strip_b64_prefix(image_base64) + user_content = [ + {"type": "text", "text": user_text}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + }, + ] + payload: dict[str, Any] = { + "model": self.conf.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user_content}, + ], + "temperature": temperature, + "stream": False, + } + if json_mode: + payload["response_format"] = {"type": "json_object"} + else: + message: dict[str, Any] = {"role": "user", "content": user_text} + if image_base64 and self.conf.send_chart_image: + message["images"] = [_strip_b64_prefix(image_base64)] + payload = { + "model": self.conf.model, + "messages": [{"role": "system", "content": system}, message], + "stream": False, + "options": {"temperature": temperature}, + } + if json_mode: + payload["format"] = "json" + + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + + if self._style == "openai": + choices = data.get("choices") or [] + if choices and isinstance(choices[0], dict): + msg = (choices[0].get("message") or {}).get("content") or "" + return str(msg) + return "" + return str((data.get("message") or {}).get("content") or "") + async def rank_funnel( self, symbol: str, @@ -36,9 +133,7 @@ class OllamaGemmaClient: ohlc_csv_block: str, image_base64: str | None, ) -> dict[str, Any]: - """ - 调用本地 Ollama,让 Gemma 按漏斗标准 JSON 回复。 - """ + """调用 Ollama 或 OpenAI 兼容网关,让 Gemma 按漏斗标准 JSON 回复。""" system = ( "你是加密货币永续合约的日线结构分析师。只输出一个 JSON 对象,不要 Markdown,不要代码围栏。" "字段必须全部存在且为英文枚举/数字:" @@ -55,29 +150,44 @@ class OllamaGemmaClient: f"程序化摘要:\n{programmatic_text}\n\n" f"最近日线 OHLCV(时间正序最后一行为最新):\n{ohlc_csv_block}\n" ) - url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat" - message: dict[str, Any] = {"role": "user", "content": user_body} - if image_base64 and self.conf.send_chart_image: - message["images"] = [image_base64] + try: + msg = await self._chat( + system=system, + user_text=user_body, + image_base64=image_base64, + temperature=self.conf.temperature, + json_mode=self.conf.json_mode, + ) + except httpx.HTTPStatusError as exc: + LOGGER.warning( + "gemma_http_error symbol=%s status=%s url=%s", + symbol, + exc.response.status_code, + exc.request.url, + ) + return { + "error": f"http_{exc.response.status_code}", + "raw": (exc.response.text or "")[:500], + "daily_structure": "weak", + "volume_view": "low", + "upside_space": "low", + "mid_resistance": "high", + "priority": 1, + "one_liner": f"模型网关 HTTP {exc.response.status_code}", + } + except Exception as exc: # noqa: BLE001 + LOGGER.warning("gemma_request_failed symbol=%s: %s", symbol, exc) + return { + "error": str(exc), + "daily_structure": "weak", + "volume_view": "low", + "upside_space": "low", + "mid_resistance": "high", + "priority": 1, + "one_liner": f"模型调用失败: {exc}", + } - payload: dict[str, Any] = { - "model": self.conf.model, - "messages": [{"role": "system", "content": system}, message], - "stream": False, - "options": {"temperature": self.conf.temperature}, - } - if self.conf.json_mode: - payload["format"] = "json" - - async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: - resp = await client.post(url, json=payload) - resp.raise_for_status() - data = resp.json() - - msg = (data.get("message") or {}).get("content") or "" parsed = _extract_json_object(msg) if msg else None - if parsed is None and isinstance(data.get("message"), dict): - parsed = _extract_json_object(str(data["message"])) if parsed is None: LOGGER.warning("gemma_parse_failed symbol=%s raw_len=%s", symbol, len(msg)) return { @@ -105,20 +215,17 @@ class OllamaGemmaClient: "要求:1) headline 一句话;2) btc_explain 解释方向;" "3) summary 覆盖 WATCH/TRIGGER/漏斗;4) risk_points 给1-3条;5) action_hint 给执行提示。" ) - url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat" - payload: dict[str, Any] = { - "model": self.conf.model, - "messages": [{"role": "system", "content": system}, {"role": "user", "content": user_body}], - "stream": False, - "options": {"temperature": 0.1}, - "format": "json", - } - async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: - resp = await client.post(url, json=payload) - resp.raise_for_status() - data = resp.json() + try: + msg = await self._chat( + system=system, + user_text=user_body, + image_base64=None, + temperature=0.1, + json_mode=True, + ) + except Exception as exc: # noqa: BLE001 + return {"error": "parse_failed", "raw": str(exc)[:1200]} - msg = (data.get("message") or {}).get("content") or "" parsed = _extract_json_object(msg) if msg else None if parsed is None: return {"error": "parse_failed", "raw": msg[:1200]} diff --git a/onchain_scout_gate/app/web.py b/onchain_scout_gate/app/web.py index da87b90..c8e1ddf 100644 --- a/onchain_scout_gate/app/web.py +++ b/onchain_scout_gate/app/web.py @@ -724,6 +724,8 @@ def create_app(settings: Settings) -> FastAPI: "gemma": { "enabled": g.enabled, "ollama_base_url": g.ollama_base_url, + "api_style": g.api_style, + "api_key_set": bool((g.api_key or "").strip()), "model": g.model, "max_funnel_per_cycle": g.max_funnel_per_cycle, "vision_top_n": g.vision_top_n, diff --git a/onchain_scout_gate/config.example.yaml b/onchain_scout_gate/config.example.yaml index ec2d235..b098b09 100644 --- a/onchain_scout_gate/config.example.yaml +++ b/onchain_scout_gate/config.example.yaml @@ -68,10 +68,15 @@ monitor: # 仅在 universe=watchlist 时使用;all_swaps 下可留空列表 watch_symbols: [] -# 本地 Ollama + Gemma 漏斗(扫描命中 → 日线+图 → JSON 打分 → 高优先级企业微信) +# Gemma 漏斗(扫描命中 → 日线+图 → JSON 打分 → 高优先级企业微信) +# 本机 Ollama:ollama_base_url: http://127.0.0.1:11434 ,api_style: ollama ,api_key 留空 +# OpenAI 兼容网关(如 op.bz121.com):base 填 https://op.bz121.com/v1 ,api_style: openai ,api_key: sk-... +# model 须与网关中登记的模型 ID 完全一致 gemma: enabled: true - ollama_base_url: "http://192.168.8.64:11434" + ollama_base_url: "https://op.bz121.com/v1" + api_style: "openai" + api_key: "sk-replace-with-your-key" model: "gemma4:e4b" timeout_seconds: 180 temperature: 0.15 diff --git a/onchain_scout_gate/docs/Gemma-Ollama网关配置.md b/onchain_scout_gate/docs/Gemma-Ollama网关配置.md new file mode 100644 index 0000000..1671cc8 --- /dev/null +++ b/onchain_scout_gate/docs/Gemma-Ollama网关配置.md @@ -0,0 +1,44 @@ +# Gemma 漏斗 · Ollama / OpenAI 兼容网关 + +## 本机 Ollama(默认) + +```yaml +gemma: + enabled: true + ollama_base_url: "http://127.0.0.1:11434" + api_style: "ollama" + api_key: "" + model: "gemma2:2b" +``` + +请求地址:`{base}/api/chat`(Ollama 原生格式)。 + +## OpenAI 兼容网关(如 op.bz121.com) + +网关说明要点: + +- 外网访问:`POST /v1/chat/completions` +- 请求头:`Authorization: Bearer sk-...` +- JSON 中 `model` 须与网关中登记的模型 ID **完全一致** + +`config.yaml` 示例: + +```yaml +gemma: + enabled: true + ollama_base_url: "https://op.bz121.com/v1" + api_style: "openai" + api_key: "sk-你的密钥" + model: "gemma4:e4b" # 改成网关里实际启用的模型名 + timeout_seconds: 180 + json_mode: true + send_chart_image: true # 需网关/模型支持多模态 +``` + +程序请求:`https://op.bz121.com/v1/chat/completions`。 + +## 注意 + +- Gemma 请求 **不走** `proxy`(与 Gate 行情代理分开),直连 `ollama_base_url`。 +- 若仅填 `api_key` 未写 `api_style`,会自动使用 `openai` 模式。 +- 网关节点未启用时,会返回 HTTP 错误,日志关键字 `gemma_http_error`。