Files
AlphaPulse/backend/src/alphapulse/ai/llm.py
T
eezd 4929a32a0a feat(news): index news packages and research persistent events
Extract reusable literal keywords once per source revision, assign complete news packages to long-lived events, and research all event members with immutable evidence and references.

Add additive research storage, vocabulary snapshots, the two-stage workspace, isolated acceptance coverage, and a paused rollout with an initial fixed 50-news production trial.
2026-11-04 12:09:58 +08:00

374 lines
18 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.
"""两种协议的模型适配:OpenAI Chat Completions 与 Anthropic Messages,统一成流式事件
消息使用与协议无关的格式(与 ChatMessage.content 一致,外加 role):
{"role": "user", "text"}
{"role": "assistant", "text", "tool_calls": [{"id", "name", "arguments"}], "thinking_blocks"?: [...]}
{"role": "tool", "tool_call_id", "name", "result"}
"""
import asyncio
import json
import logging
from collections.abc import AsyncIterator
from contextvars import ContextVar
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from dataclasses import dataclass, field
from typing import Any, Literal
import anthropic
import httpx
import httpx2 # anthropic SDK 使用的 HTTP 客户端,异常类与 httpx 不通用
import openai
from alphapulse.core.crypto import decrypt
from alphapulse.models import AiProvider
# 高思考强度时 OpenAI 格式在推理期间不推送任何内容,读超时要留足
TIMEOUT_SECONDS = 600
# 思考强度:两种协议共有的取值。OpenAI 另有 none/minimal,但实测经 Sub2API 会卡住后 502,不提供
Effort = Literal["low", "medium", "high", "xhigh", "max"]
# SDK 自带重试只针对连接错误、429 和 5xx
MAX_RETRIES = 2
# 只重试 SDK 不处理的流内错误和读取中断,且只在还没有输出内容时重试。
STREAM_RETRIES = 3
STREAM_RETRY_DELAY_SECONDS = 2
# 输出被截断时回给模型的工具结果:参数不完整,不能执行
TRUNCATED_RESULT = json.dumps({"error": "输出达到最大输出上限被截断,工具参数不完整,未执行。请精简内容后重新调用"}, ensure_ascii=False)
logger = logging.getLogger("alphapulse.ai.llm")
news_retry = ContextVar("news_model_retry", default=None)
class LlmError(Exception):
"""模型调用失败;message 可直接展示给用户"""
@dataclass
class ToolSpec:
name: str
description: str
# JSON Schema
parameters: dict
@dataclass
class Completion:
text: str = ""
tool_calls: list[dict] = field(default_factory=list)
usage: dict = field(default_factory=dict)
# Anthropic 的 thinking / redacted_thinking 块:工具调用循环中必须原样回传,否则接口报错
thinking_blocks: list[dict] = field(default_factory=list)
# 达到 max_tokens 被截断:工具调用的参数可能不完整,不能执行
truncated: bool = False
@dataclass
class TextDelta:
text: str
@dataclass
class ThinkingStarted:
"""模型开始思考(只有 Claude 格式会推送);前端据此显示「思考中」"""
def api_root(base_url: str) -> str:
"""用户可能填 https://host 或 https://host/v1,统一成不带 /v1 的根地址"""
root = base_url.rstrip("/")
return root.removesuffix("/v1")
async def list_models(provider: AiProvider) -> list[str]:
"""Read the provider's model catalog without generating a completion."""
options = {'api_key': decrypt(provider.api_key_encrypted), 'timeout': 10, 'max_retries': 0}
root = api_root(provider.base_url)
client = (openai.AsyncOpenAI(base_url=f'{root}/v1', **options) if provider.protocol == 'openai'
else anthropic.AsyncAnthropic(base_url=root, **options))
try:
async with asyncio.timeout(30), client:
models = await client.models.list()
identifiers = set()
async for model in models:
identifier = getattr(model, 'id', None)
if not isinstance(identifier, str) or not identifier.strip():
raise LlmError('模型列表响应缺少有效的模型 ID;请手动填写模型名称')
identifiers.add(identifier)
return sorted(identifiers)
except (openai.APIStatusError, anthropic.APIStatusError) as error:
raise LlmError(f'模型列表接口返回 HTTP {error.status_code};请检查接入权限,或手动填写模型名称') from error
except (TimeoutError, openai.APITimeoutError, anthropic.APITimeoutError,
httpx.TimeoutException, httpx2.TimeoutException) as error:
raise LlmError('读取模型列表超时;可重试或手动填写模型名称') from error
except (openai.APIError, anthropic.APIError, httpx.TransportError, httpx2.TransportError) as error:
raise LlmError('无法读取模型列表;请检查接入配置,或手动填写模型名称') from error
async def stream(
provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None = None,
) -> AsyncIterator[TextDelta | ThinkingStarted | Completion]:
"""先逐段产出 TextDelta / ThinkingStarted,最后产出一个 Completion。effort 为空表示不传,由模型决定"""
policy = news_retry.get()
retries = 2 if policy is not None else STREAM_RETRIES
for attempt in range(retries + 1):
output = False
if policy is not None: policy["output"] = False
try:
if provider.protocol == "openai":
events = _stream_openai(provider, system, messages, tools, effort)
else:
events = _stream_anthropic(provider, system, messages, tools, effort)
async for event in events:
output = True
yield event
return
# 流式读取过程中的网络错误 SDK 不包装,以原始的 httpx / httpx2 异常抛出
except (openai.APIError, anthropic.APIError, httpx.TransportError, httpx2.TransportError) as e:
# 已经推送过内容再重试会重复输出
delay = _news_retry_delay(e, attempt) if policy is not None else STREAM_RETRY_DELAY_SECONDS
retryable = delay is not None if policy is not None else _retryable(e)
if output or (policy is not None and policy.get("output")) or attempt == retries or not retryable:
raise _llm_error(e) from e
if policy is not None:
from alphapulse.news_risk.progress import step
step(f"上游暂时拒绝:{_llm_error(e)};{delay:g}秒后重试({attempt + 1}/2)")
logger.warning("模型调用失败,%ss 后第 %s 次重试:%s", delay, attempt + 1, _llm_error(e))
await asyncio.sleep(delay)
if policy is not None:
from alphapulse.news_risk.progress import step
step(f"等待模型响应(重试 {attempt + 1}/2)")
def _news_retry_delay(error, attempt):
"""One bounded retry owner for news; never repeat a timed out request."""
if isinstance(error, (openai.APITimeoutError, anthropic.APITimeoutError, httpx.TimeoutException, httpx2.TimeoutException)):
return None
status = getattr(error, 'status_code', None)
body = getattr(error, 'body', None)
detail = body.get('error', body) if isinstance(body, dict) else {}
message = str((detail.get('message') if isinstance(detail, dict) else detail) or getattr(error, 'message', '')).lower()
concurrency = _in_stream_error(error) and 'concurrency limit exceeded' in message
connection = isinstance(error, (openai.APIConnectionError, anthropic.APIConnectionError, httpx.TransportError, httpx2.TransportError))
if not (status == 429 or status is not None and 500 <= status <= 599 or concurrency or connection):
return None
delay = 30 * (attempt + 1)
response = getattr(error, 'response', None)
header = response.headers.get('retry-after') if response is not None else None
if header:
try:
try: seconds = float(header)
except ValueError: seconds = (parsedate_to_datetime(header) - datetime.now(UTC)).total_seconds()
if seconds > 120: return None
if seconds > 0: delay = max(delay, seconds)
except (ValueError, TypeError, OverflowError):
pass
return delay
def _news_output():
policy = news_retry.get()
if policy is not None: policy['output'] = True
def _in_stream_error(e: Exception) -> bool:
"""连接建立后上游在流内返回的错误事件(如 overloaded)。
OpenAI SDK 抛 APIError 基类;Anthropic SDK 抛 APIStatusError,状态码是流本身的 200"""
if isinstance(e, openai.APIError):
return not isinstance(e, (openai.APIStatusError, openai.APIConnectionError))
return isinstance(e, anthropic.APIStatusError) and e.status_code == 200
def _retryable(e: Exception) -> bool:
"""建连重试归 SDK;这里只处理流内错误与读取中断,超时不重试。"""
if isinstance(e, (openai.APITimeoutError, anthropic.APITimeoutError, httpx.TimeoutException, httpx2.TimeoutException)):
return False
return _in_stream_error(e) or isinstance(
e, (httpx.TransportError, httpx2.TransportError),
)
def _llm_error(e: Exception) -> LlmError:
if _in_stream_error(e):
error = e.body.get("error") if isinstance(e.body, dict) else None
message = error.get("message") if isinstance(error, dict) else None
return LlmError(f"模型接口出错:{message or e.message}")
if isinstance(e, (openai.APIStatusError, anthropic.APIStatusError)):
return LlmError(f"模型接口返回 {e.status_code}:{e.message}")
if isinstance(e, (openai.APITimeoutError, anthropic.APITimeoutError, httpx.TimeoutException, httpx2.TimeoutException)):
return LlmError(f"模型接口超时({TIMEOUT_SECONDS}s)")
if isinstance(e, (openai.APIConnectionError, anthropic.APIConnectionError)):
return LlmError(f"无法连接模型接口:{e}")
if isinstance(e, (httpx.TransportError, httpx2.TransportError)):
return LlmError(f"模型接口连接中断:{e!r}")
return LlmError(f"模型接口出错:{e}")
# ---------- OpenAI ----------
def _openai_messages(system: str, messages: list[dict]) -> list[dict]:
out: list[dict] = [{"role": "system", "content": system}]
for m in messages:
if m["role"] == "user":
out.append({"role": "user", "content": m["text"]})
elif m["role"] == "assistant":
if not m["text"] and not m.get("tool_calls"):
# 停止得太早或模型没有输出:空的 assistant 消息会被接口拒绝
continue
msg: dict[str, Any] = {"role": "assistant", "content": m["text"] or None}
if m.get("tool_calls"):
msg["tool_calls"] = [
{"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": json.dumps(c["arguments"], ensure_ascii=False)}}
for c in m["tool_calls"]
]
out.append(msg)
else:
out.append({"role": "tool", "tool_call_id": m["tool_call_id"], "content": m["result"]})
return out
async def _stream_openai(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None) -> AsyncIterator[TextDelta | Completion]:
client = openai.AsyncOpenAI(
api_key=decrypt(provider.api_key_encrypted), base_url=f"{api_root(provider.base_url)}/v1",
timeout=TIMEOUT_SECONDS, max_retries=0 if news_retry.get() is not None else MAX_RETRIES,
)
kwargs: dict[str, Any] = {}
if tools:
kwargs["tools"] = [{"type": "function", "function": {"name": t.name, "description": t.description, "parameters": t.parameters}} for t in tools]
if effort:
kwargs["reasoning_effort"] = effort
result = Completion()
# 按 index 拼接分片到达的工具调用
calls: dict[int, dict] = {}
finished = False
async with client:
chunks = await client.chat.completions.create(
model=provider.model, messages=_openai_messages(system, messages), max_tokens=provider.max_tokens,
stream=True, stream_options={"include_usage": True}, **kwargs,
)
async for chunk in chunks:
if chunk.usage:
result.usage = {"input_tokens": chunk.usage.prompt_tokens, "output_tokens": chunk.usage.completion_tokens}
if not chunk.choices:
continue
reason = chunk.choices[0].finish_reason
if reason == "content_filter":
raise LlmError("模型输出被内容过滤器中止")
if reason is not None:
if reason not in ("stop", "tool_calls", "length"):
raise LlmError(f"模型返回了不支持的结束原因:{reason}")
finished = True
if reason == "length":
result.truncated = True
delta = chunk.choices[0].delta
if delta.content or delta.tool_calls or getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None):
_news_output()
if delta.content:
result.text += delta.content
yield TextDelta(delta.content)
for tc in delta.tool_calls or []:
call = calls.setdefault(tc.index, {"id": "", "name": "", "arguments": ""})
if tc.id:
call["id"] = tc.id
if tc.function and tc.function.name:
call["name"] += tc.function.name
if tc.function and tc.function.arguments:
call["arguments"] += tc.function.arguments
if not finished:
raise LlmError("模型连接提前结束,未收到完整响应的结束标志")
result.tool_calls = [_parse_call(c["id"], c["name"], c["arguments"], result.truncated) for _, c in sorted(calls.items())]
yield result
# ---------- Anthropic ----------
def _anthropic_messages(messages: list[dict]) -> list[dict]:
out: list[dict] = []
for m in messages:
if m["role"] == "user":
out.append({"role": "user", "content": m["text"]})
elif m["role"] == "assistant":
# 思考块必须排在最前,原样回传
blocks: list[dict] = list(m.get("thinking_blocks") or [])
if m["text"]:
blocks.append({"type": "text", "text": m["text"]})
blocks += [{"type": "tool_use", "id": c["id"], "name": c["name"], "input": c["arguments"]} for c in m.get("tool_calls") or []]
# 空的 assistant 消息会被接口拒绝
if blocks:
out.append({"role": "assistant", "content": blocks})
else:
block = {"type": "tool_result", "tool_use_id": m["tool_call_id"], "content": m["result"]}
# 同一轮的多个工具结果必须放进同一条 user 消息
if out and out[-1]["role"] == "user" and isinstance(out[-1]["content"], list):
out[-1]["content"].append(block)
else:
out.append({"role": "user", "content": [block]})
return out
async def _stream_anthropic(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None) -> AsyncIterator[TextDelta | ThinkingStarted | Completion]:
client = anthropic.AsyncAnthropic(
api_key=decrypt(provider.api_key_encrypted), base_url=api_root(provider.base_url),
timeout=TIMEOUT_SECONDS, max_retries=0 if news_retry.get() is not None else MAX_RETRIES,
)
kwargs: dict[str, Any] = {}
if tools:
kwargs["tools"] = [{"name": t.name, "description": t.description, "input_schema": t.parameters} for t in tools]
if effort:
# 思考强度通过 output_config.effort 控制,配合自适应思考(由模型决定是否、思考多久)
kwargs["output_config"] = {"effort": effort}
kwargs["thinking"] = {"type": "adaptive"}
result = Completion()
blocks: dict[int, dict] = {}
thinking: dict[int, dict] = {}
finished = False
async with client:
events = await client.messages.create(
model=provider.model, system=system, messages=_anthropic_messages(messages), max_tokens=provider.max_tokens,
stream=True, **kwargs,
)
async for event in events:
if event.type in ("content_block_start", "content_block_delta"): _news_output()
if event.type == "message_start":
result.usage["input_tokens"] = event.message.usage.input_tokens
elif event.type == "content_block_start" and event.content_block.type == "tool_use":
blocks[event.index] = {"id": event.content_block.id, "name": event.content_block.name, "arguments": ""}
elif event.type == "content_block_start" and event.content_block.type == "thinking":
thinking[event.index] = {"type": "thinking", "thinking": event.content_block.thinking, "signature": event.content_block.signature}
yield ThinkingStarted()
elif event.type == "content_block_start" and event.content_block.type == "redacted_thinking":
thinking[event.index] = {"type": "redacted_thinking", "data": event.content_block.data}
yield ThinkingStarted()
elif event.type == "content_block_delta":
if event.delta.type == "text_delta":
result.text += event.delta.text
yield TextDelta(event.delta.text)
elif event.delta.type == "input_json_delta":
blocks[event.index]["arguments"] += event.delta.partial_json
elif event.delta.type == "thinking_delta":
thinking[event.index]["thinking"] += event.delta.thinking
elif event.delta.type == "signature_delta":
thinking[event.index]["signature"] += event.delta.signature
elif event.type == "message_delta":
result.usage["output_tokens"] = event.usage.output_tokens
result.truncated = event.delta.stop_reason == "max_tokens"
elif event.type == "message_stop":
finished = True
if not finished:
raise LlmError("模型连接提前结束,未收到完整响应的结束标志")
result.tool_calls = [_parse_call(b["id"], b["name"], b["arguments"], result.truncated) for _, b in sorted(blocks.items())]
result.thinking_blocks = [b for _, b in sorted(thinking.items())]
yield result
def _parse_call(call_id: str, name: str, arguments: str, truncated: bool) -> dict:
if truncated:
# 截断的参数是半截 JSON,不解析;调用方不会执行它
return {"id": call_id, "name": name, "arguments": {}}
try:
parsed = json.loads(arguments) if arguments else {}
except json.JSONDecodeError as e:
raise LlmError(f"模型返回的工具参数不是合法 JSON:{name} {arguments[:200]}") from e
return {"id": call_id, "name": name, "arguments": parsed}