Files
daily-robots/daily/llm_client.py
yumao f166d1e504 fix: LLM 客户端自建大超时 Client,绕开 SDK 默认 60s unary 超时
CURSOR_MODEL=auto 时后端首 token 常超过 SDK 默认 unary_timeout,
自建带 DAILY_CURSOR_UNARY_TIMEOUT(300s)/DAILY_CURSOR_STREAM_TIMEOUT(900s)
的 Client 并显式传入 Agent.prompt,finally 中关闭释放资源。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:58:09 +08:00

138 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 只有 60sCURSOR_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"))