Support selectable demo exchanges and configurable analysis limits

Persist manual requests until exchange confirmation, preserve stop protection, and reconcile fills before settling plans. Tighten model stream, crawler, skill, research accounting, and frontend race handling, with regression coverage. Keep queued analyses pending until a fresh account snapshot is available.
This commit is contained in:
eezd committed 2026-10-20 18:45:00 +08:00
1 parent fcaedfb358
commit 285b3952d7
33 files changed
+1890 -325

No files matched your search

+19 -5
View File
@@ -27,8 +27,7 @@ TIMEOUT_SECONDS = 600
Effort = Literal["low", "medium", "high", "xhigh", "max"]
# SDK 自带重试只针对连接错误、429 和 5xx
MAX_RETRIES = 2
# 流内错误(上游过载等)和流式读取中的网络中断 SDK 不重试;连接失败 SDK 重试后仍可能失败(上游短暂不可达)。
# 这些都由这里再重试,且只在还没有输出内容时重试
# 只重试 SDK 不处理的流内错误和读取中断,且只在还没有输出内容时重试。
STREAM_RETRIES = 3
STREAM_RETRY_DELAY_SECONDS = 2
# 输出被截断时回给模型的工具结果:参数不完整,不能执行
@@ -109,11 +108,11 @@ def _in_stream_error(e: Exception) -> bool:
def _retryable(e: Exception) -> bool:
"""流内错误事件、连接失败、读取中断可重试;超时不重试(单次已等 TIMEOUT_SECONDS)"""
"""建连重试归 SDK;这里只处理流内错误与读取中断,超时不重试。"""
if isinstance(e, (openai.APITimeoutError, anthropic.APITimeoutError, httpx.TimeoutException, httpx2.TimeoutException)):
return False
return _in_stream_error(e) or isinstance(
e, (openai.APIConnectionError, anthropic.APIConnectionError, httpx.TransportError, httpx2.TransportError),
e, (httpx.TransportError, httpx2.TransportError),
)
@@ -169,6 +168,7 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
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,
@@ -179,7 +179,14 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
result.usage = {"input_tokens": chunk.usage.prompt_tokens, "output_tokens": chunk.usage.completion_tokens}
if not chunk.choices:
continue
if chunk.choices[0].finish_reason == "length":
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:
@@ -193,6 +200,8 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
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
@@ -238,6 +247,7 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
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,
@@ -267,6 +277,10 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
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