daily 自建 Cursor bridge;DAILY_LLM_PROVIDER 控制后端;config/feeds.yaml 与 sensitive_words 可配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
3.4 KiB
Python
122 lines
3.4 KiB
Python
"""LLM 调用共享工具(OpenAI 兼容 API / Cursor SDK)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
import certifi
|
||
import httpx
|
||
|
||
from daily.config import env, env_int
|
||
from daily.cursor_client import cursor_chat
|
||
|
||
_JSON_BLOCK = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||
|
||
|
||
def extract_json_object(text: str) -> dict[str, Any]:
|
||
text = text.strip()
|
||
if not text:
|
||
return {}
|
||
try:
|
||
data = json.loads(text)
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
pass
|
||
match = _JSON_BLOCK.search(text)
|
||
if match:
|
||
try:
|
||
data = json.loads(match.group(1).strip())
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
pass
|
||
start, end = text.find("{"), text.rfind("}")
|
||
if start >= 0 and end > start:
|
||
try:
|
||
data = json.loads(text[start : end + 1])
|
||
return data if isinstance(data, dict) else {}
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def _has_openai_configured() -> bool:
|
||
return bool(env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY"))
|
||
|
||
|
||
def _has_cursor_configured() -> bool:
|
||
return bool(env("CURSOR_API_KEY"))
|
||
|
||
|
||
def llm_provider() -> str:
|
||
raw = (env("DAILY_LLM_PROVIDER") or "auto").strip().lower()
|
||
if raw in {"openai", "cursor"}:
|
||
return raw
|
||
return "auto"
|
||
|
||
|
||
def resolve_llm_backend() -> str:
|
||
"""返回 openai | cursor | 空字符串。"""
|
||
provider = llm_provider()
|
||
has_openai = _has_openai_configured()
|
||
has_cursor = _has_cursor_configured()
|
||
|
||
if provider == "openai":
|
||
if has_openai:
|
||
return "openai"
|
||
return "cursor" if has_cursor else ""
|
||
|
||
if provider == "cursor":
|
||
if has_cursor:
|
||
return "cursor"
|
||
return "openai" if has_openai else ""
|
||
|
||
agent_mode = (env("DAILY_REPORT_MODE") or "").strip().lower() == "agent"
|
||
if agent_mode and has_cursor:
|
||
return "cursor"
|
||
if has_openai:
|
||
return "openai"
|
||
if has_cursor:
|
||
return "cursor"
|
||
return ""
|
||
|
||
|
||
def _openai_chat(system: str, user: str) -> str:
|
||
api_key = (env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY") or "").strip()
|
||
if not api_key:
|
||
return ""
|
||
base = (env("DAILY_LLM_API_BASE") or env("OPENAI_API_BASE") or "https://api.openai.com/v1").rstrip("/")
|
||
model = env("DAILY_LLM_MODEL") or env("OPENAI_MODEL") or "gpt-4o-mini"
|
||
timeout = env_int("DAILY_LLM_TIMEOUT", 120)
|
||
payload = {
|
||
"model": model,
|
||
"temperature": 0.2,
|
||
"messages": [
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": user},
|
||
],
|
||
}
|
||
with httpx.Client(timeout=timeout, verify=certifi.where()) as client:
|
||
resp = client.post(
|
||
f"{base}/chat/completions",
|
||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||
json=payload,
|
||
)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return str(data["choices"][0]["message"]["content"] or "").strip()
|
||
|
||
|
||
def llm_chat(system: str, user: str) -> str:
|
||
backend = resolve_llm_backend()
|
||
if backend == "openai":
|
||
return _openai_chat(system, user)
|
||
if backend == "cursor":
|
||
return cursor_chat(system, user)
|
||
return ""
|
||
|
||
|
||
def has_llm_configured() -> bool:
|
||
return bool(resolve_llm_backend())
|