Files
loom/backend/app/integrations/lobster_client.py
T
2026-04-24 17:46:03 +08:00

143 lines
5.1 KiB
Python

"""龙虾 Agent HTTP 客户端。
运行时配置来源优先级:DB (lobster_config 表) > .env (settings.LOBSTER_*)。
调用方通过 `build_lobster_client(db)` 或 `build_lobster_client_sync(api_base, api_key, timeout)`
获取实例;不再使用模块级单例,便于配置热更新。
- 当 api_base / api_key 任一为空时走 mock(便于本地自测)。
- 真调协议:POST {api_base}/runs 提交、GET {api_base}/runs/{run_id} 查状态。
实际字段由联调时与龙虾平台对齐。
"""
from __future__ import annotations
import asyncio
import random
import time
from dataclasses import dataclass
from typing import Any
import httpx
from app.core.logging import logger
@dataclass
class LobsterRunResult:
run_id: str
status: str # QUEUED / RUNNING / SUCCEEDED / FAILED / TIMEOUT
output: dict[str, Any] | None = None
logs: list[dict[str, Any]] | None = None # [{"event_id": str, "level": str, "message": str}]
message: str | None = None
# 模块级共享 mock 状态机(同一 run_id 多次 poll 可继续推进)
_MOCK_RUNS: dict[str, dict[str, Any]] = {}
class LobsterClient:
"""按运行时配置构造;API_BASE / API_KEY 任一为空时退化为 mock。"""
def __init__(self, api_base: str = "", api_key: str = "", timeout: int = 30) -> None:
self.base = (api_base or "").rstrip("/")
self.key = api_key or ""
self.timeout = timeout or 30
@property
def is_mock(self) -> bool:
return not self.base or not self.key
async def submit(
self, template_id: str, params: dict[str, Any], task_no: str
) -> LobsterRunResult:
if self.is_mock:
return await self._mock_submit(template_id, params, task_no)
headers = {
"Authorization": f"Bearer {self.key}",
"X-Loom-Task": task_no,
"Content-Type": "application/json",
}
payload = {"template_id": template_id, "input": params, "task_no": task_no}
async with httpx.AsyncClient(timeout=self.timeout) as client:
r = await client.post(f"{self.base}/runs", json=payload, headers=headers)
r.raise_for_status()
data = r.json()
return LobsterRunResult(
run_id=str(data.get("run_id") or data.get("id") or ""),
status=str(data.get("status") or "QUEUED"),
message=data.get("message"),
)
async def poll(self, run_id: str) -> LobsterRunResult:
if self.is_mock:
return await self._mock_poll(run_id)
headers = {"Authorization": f"Bearer {self.key}"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
r = await client.get(f"{self.base}/runs/{run_id}", headers=headers)
r.raise_for_status()
data = r.json()
return LobsterRunResult(
run_id=run_id,
status=str(data.get("status") or "RUNNING"),
output=data.get("output"),
logs=data.get("logs") or [],
message=data.get("message"),
)
# ---- mock ----
async def _mock_submit(
self, template_id: str, params: dict[str, Any], task_no: str
) -> LobsterRunResult:
run_id = f"mock-{task_no}-{int(time.time()*1000)}"
_MOCK_RUNS[run_id] = {
"created_at": time.time(),
"template_id": template_id,
"input": params,
"polls": 0,
}
logger.info("lobster_mock_submit", run_id=run_id, template_id=template_id)
await asyncio.sleep(0.05)
return LobsterRunResult(run_id=run_id, status="QUEUED")
async def _mock_poll(self, run_id: str) -> LobsterRunResult:
state = _MOCK_RUNS.get(run_id)
if state is None:
return LobsterRunResult(run_id=run_id, status="FAILED", message="mock run_id 未知")
state["polls"] += 1
if state["polls"] == 1:
return LobsterRunResult(
run_id=run_id,
status="RUNNING",
logs=[{"event_id": f"{run_id}-1", "level": "INFO", "message": "mock: 开始执行"}],
)
if state["polls"] >= 3:
output = {"mock": True, "input": state["input"], "random": random.randint(1, 99)}
return LobsterRunResult(
run_id=run_id,
status="SUCCEEDED",
output=output,
logs=[{"event_id": f"{run_id}-2", "level": "INFO", "message": "mock: 完成"}],
message="mock 成功",
)
return LobsterRunResult(
run_id=run_id,
status="RUNNING",
logs=[{"event_id": f"{run_id}-poll-{state['polls']}", "level": "INFO",
"message": f"mock: 第 {state['polls']} 次轮询"}],
)
async def build_lobster_client(db) -> LobsterClient:
"""根据当前 DB + .env 生效配置构造 client。调用方在执行任务前拿一次即可。"""
# 局部 import 避免模块循环依赖
from app.services import lobster_config_service
eff = await lobster_config_service.load_effective(db)
return LobsterClient(
api_base=eff.api_base,
api_key=eff.api_key,
timeout=eff.timeout_seconds,
)