"""LLM 调用共享工具(OpenAI 兼容 API / Cursor SDK)。""" from __future__ import annotations import json import logging import os import re from typing import Any import certifi import httpx from daily.config import ROOT, env, env_int logger = logging.getLogger(__name__) _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 _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 _cursor_chat(system: str, user: str) -> str: api_key = (env("CURSOR_API_KEY") or "").strip() if not api_key: return "" from cursor_sdk import Agent, AgentOptions, Client, CursorAgentError, LocalAgentOptions from daily.bridge_manager import warm_cursor_bridge cwd = env("DAILY_CURSOR_CWD") or str(ROOT) os.environ["CURSOR_CWD"] = cwd warm_cursor_bridge() model = env("CURSOR_MODEL") or "composer-2.5" prompt = f"{system}\n\n{user}" # SDK 默认 unary_timeout 只有 60s,CURSOR_MODEL=auto 时后端首 token 常超时; # 自建带大超时的 Client 绕开 _default_client(),unary/stream 都放大。 unary_timeout = env_int("DAILY_CURSOR_UNARY_TIMEOUT", 300) stream_timeout = env_int("DAILY_CURSOR_STREAM_TIMEOUT", 900) client = Client( base_url=os.environ["CURSOR_SDK_BRIDGE_URL"], auth_token=os.environ["CURSOR_SDK_BRIDGE_TOKEN"], unary_timeout=unary_timeout, stream_timeout=stream_timeout, ) try: result = Agent.prompt( prompt, AgentOptions( api_key=api_key, model=model, local=LocalAgentOptions(cwd=cwd), ), client=client, ) except CursorAgentError as exc: raise RuntimeError(f"LLM 调用失败:{exc.message}") from exc finally: client.close() if result.status == "error": raise RuntimeError(f"LLM 调用失败:{result.result or '未知错误'}") return (result.result or "").strip() def llm_chat(system: str, user: str) -> str: """优先 OpenAI 兼容 API,否则 Cursor SDK。""" if env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY"): return _openai_chat(system, user) if env("CURSOR_API_KEY"): return _cursor_chat(system, user) return "" def has_cursor_configured() -> bool: return bool((env("CURSOR_API_KEY") or "").strip()) def cursor_agent_prompt(system: str, user: str) -> str: """仅 Cursor SDK Agent(可用 WebSearch 等工具),不走 OpenAI 兼容 API。""" if not has_cursor_configured(): return "" return _cursor_chat(system, user) def has_llm_configured() -> bool: return bool(env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY") or env("CURSOR_API_KEY"))