chore(polling): 调整各档位轮询间隔范围 (#96)

* fix(ai): 兼容 reasoning 模型 temperature 限制 + 透出上游真实错误

Kimi kimi-k2.7-code 等 reasoning 模型拒绝非约定 temperature (Moonshot
报 "only 1 is allowed for this model"), 之前无条件下发导致 400 配置失败。

- 捕获 temperature 相关 400 后自动去掉 temperature 重试一次 (非流式 + 流式),
  不再依赖模型名猜测, 对任意 reasoning 模型稳健
- _format_openai_error 优先透出上游真实 detail, 仅无可读 detail 时回落到
  状态码通用文案, 避免吞掉 "model not found" 等排障关键信息
- 前端 Kimi 预设 model 更正为 kimi-k2.7-code

* chore(polling): 调整各档位轮询间隔范围

实时行情 (quote_service):
- pro 最小间隔 2s → 3s, starter 3s → 6s (expert/free 不变)
- DEFAULT_INTERVAL 10s → 6s

五档盘口 (depth_service):
- expert 区间上限 300s → 120s
- 默认值 20s → 10s

前端兜底默认值同步 (Data/Monitoring/DepthConfigCard):
- quote interval fallback 10→6, min 5→6
- depth interval fallback 20→10, expert hi 300→120

---------

Co-authored-by: shy3130 <shy3130@users.noreply.github.com>
This commit is contained in:
wshy
2026-07-11 10:22:11 +08:00
committed by GitHub
co-authored by shy3130
parent de1b1713fa
commit 773cd2fe12
10 changed files with 167 additions and 39 deletions
+1 -1
View File
@@ -970,7 +970,7 @@ def get_quote_interval(request: Request) -> dict:
"""获取当前行情轮询间隔和档位限制。"""
qs = getattr(request.app.state, "quote_service", None)
if not qs:
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
return {"interval": 6.0, "min_interval": 6.0, "max_interval": 60.0}
return {
"interval": qs._interval,
"min_interval": qs.get_min_interval(),
+76 -20
View File
@@ -100,7 +100,7 @@ def ai_configured(provider: str | None = None) -> bool:
async def generate_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.3,
temperature: float | None = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
) -> str:
@@ -118,7 +118,7 @@ async def generate_ai_text(
async def stream_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.5,
temperature: float | None = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
) -> AsyncIterator[str]:
@@ -143,7 +143,7 @@ async def stream_ai_text(
async def _run_openai_once(
messages: Sequence[Message],
*,
temperature: float,
temperature: float | None,
max_tokens: int,
timeout: float,
) -> str:
@@ -152,17 +152,28 @@ async def _run_openai_once(
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
model = current_ai_model()
req_messages = list(messages)
try:
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
model=model,
messages=req_messages,
**_openai_kwargs(temperature=temperature, max_tokens=max_tokens),
)
except Exception as exc:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
raise
# Reasoning 类模型 (如 kimi-k2.7-code, deepseek-r1, o 系列) 拒绝非约定
# temperature (Moonshot 报 "only 1 is allowed for this model")。不再靠
# 模型名猜测, 而是捕获该错误后去掉 temperature 重试一次 —— 对所有此类模型都稳。
if temperature is not None and _is_temperature_rejected(exc):
resp = await client.chat.completions.create(
model=model,
messages=req_messages,
**_openai_kwargs(temperature=None, max_tokens=max_tokens),
)
else:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
raise
if not resp.choices:
return ""
return (resp.choices[0].message.content or "").strip()
@@ -171,7 +182,7 @@ async def _run_openai_once(
async def _stream_openai(
messages: Sequence[Message],
*,
temperature: float,
temperature: float | None,
max_tokens: int,
timeout: float,
) -> AsyncIterator[str]:
@@ -180,19 +191,39 @@ async def _stream_openai(
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
try:
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
model = current_ai_model()
req_messages = list(messages)
async def _iter(stream):
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
try:
stream = await client.chat.completions.create(
model=model,
messages=req_messages,
**_openai_kwargs(temperature=temperature, max_tokens=max_tokens),
stream=True,
)
except Exception as exc:
# 流尚未开始 yield, 可安全重建: 去掉 temperature 后重开 stream。
if temperature is not None and _is_temperature_rejected(exc):
stream = await client.chat.completions.create(
model=model,
messages=req_messages,
**_openai_kwargs(temperature=None, max_tokens=max_tokens),
stream=True,
)
else:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
raise
try:
async for piece in _iter(stream):
yield piece
except Exception as exc:
if _is_openai_transport_error(exc):
raise RuntimeError(_format_openai_error(exc)) from exc
@@ -212,6 +243,29 @@ def _openai_client(api_key: str, timeout: float):
)
# Reasoning / thinking 类模型 (kimi-k2.7-code, deepseek-r1, OpenAI o 系列等) 不接受
# 任意 temperature, 上游会以 400 拒绝 (如 Moonshot: "only 1 is allowed for this model")。
# 这里不靠模型名猜测, 而是在真正命中该错误后自动去掉 temperature 重试 (见
# _run_openai_once / _stream_openai), 对任意 reasoning 模型都稳健。
_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed", "unsupported parameter")
def _is_temperature_rejected(exc: Exception) -> bool:
"""True if the upstream 400 is specifically about the temperature param."""
if getattr(exc, "status_code", None) != 400:
return False
text = _openai_error_detail(exc) or str(exc)
return any(h in text.lower() for h in _TEMP_REJECT_HINTS)
def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict:
"""Build OpenAI create() kwargs; temperature omitted when None."""
kwargs: dict = {"max_tokens": max_tokens}
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
def _is_openai_transport_error(exc: Exception) -> bool:
try:
import openai
@@ -254,7 +308,9 @@ def _format_openai_error(exc: Exception) -> str:
503: "AI 服务暂时不可用, 请稍后重试",
504: "AI 上游服务超时, 请稍后重试或检查 AI Base URL / 网络",
}
message = status_messages.get(status) or detail or "请稍后重试或检查 AI 服务配置"
# 优先透出上游真实错误 (如 Moonshot 的 "model not found"), 仅在没有
# 可读 detail 时才回落到按状态码的通用文案, 避免吞掉排障关键信息。
message = detail or status_messages.get(status) or "请稍后重试或检查 AI 服务配置"
if status:
return f"AI 服务请求失败({status}): {message}"
return f"AI 服务请求失败: {message}"
+2 -2
View File
@@ -14,7 +14,7 @@
最后拉一次 → 落盘 depth5 parquet(定版)
三层防护节流("设过大设上限, 设过小设最小值"):
① 套餐范围 clamp: Pro 10~120s, Expert 3~300s
① 套餐范围 clamp: Pro 10~120s, Expert 3~120s
② 限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢
③ 系统接管通知: 用户设置会超限时, 推 toast 告知已自动调整
"""
@@ -41,7 +41,7 @@ logger = logging.getLogger(__name__)
# 套餐 → (轮询间隔下限s, 上限s)
TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = {
"pro": (10.0, 120.0),
"expert": (3.0, 300.0),
"expert": (3.0, 120.0),
}
# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围
DEFAULT_RANGE = (10.0, 120.0)
+3 -3
View File
@@ -50,7 +50,7 @@ def get_indices_nav_pinned() -> bool:
def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 10.0)
return load().get("realtime_quote_interval", 6.0)
def get_realtime_watchlist_symbols() -> list[str]:
@@ -253,8 +253,8 @@ def get_limit_ladder_monitor_enabled() -> bool:
def get_depth_polling_interval() -> float:
"""depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。"""
return float(load().get("depth_polling_interval", 20.0))
"""depth 盘中轮询间隔(秒)。默认 10(Pro/Expert 都适用)。"""
return float(load().get("depth_polling_interval", 10.0))
def set_depth_polling_interval(interval: float) -> float:
+3 -3
View File
@@ -137,11 +137,11 @@ class QuoteService:
# 档位 → 最小轮询间隔 (秒)
TIER_MIN_INTERVAL = {
"expert": 1.0,
"pro": 2.0,
"starter": 3.0,
"pro": 3.0,
"starter": 6.0,
"free": 6.0,
}
DEFAULT_INTERVAL = 10.0
DEFAULT_INTERVAL = 6.0
MAX_INTERVAL = 60.0
def __init__(self) -> None:
+74 -2
View File
@@ -3,7 +3,11 @@ from __future__ import annotations
import httpx
import openai
from app.services.ai_provider import _format_openai_error, normalize_openai_base_url
from app.services.ai_provider import (
_format_openai_error,
_is_temperature_rejected,
normalize_openai_base_url,
)
def test_normalize_openai_base_url_adds_v1_for_root_gateway():
@@ -53,7 +57,8 @@ def test_format_openai_error_hides_html_gateway_body():
assert "Gateway Timeout" not in message
def test_format_openai_error_uses_status_message_when_available():
def test_format_openai_error_prefers_upstream_detail_when_available():
"""有可读的上游 detail 时优先透出, 而不是用 400 通用文案吞掉。"""
response = httpx.Response(
400,
json={"error": {"message": "model context length exceeded"}},
@@ -67,4 +72,71 @@ def test_format_openai_error_uses_status_message_when_available():
message = _format_openai_error(exc)
assert message == "AI 服务请求失败(400): model context length exceeded"
def test_format_openai_error_falls_back_to_status_message_without_detail():
"""上游无可读 detail (如 HTML 网关页) 时, 才回落到 400 通用文案。"""
response = httpx.Response(
400,
headers={"content-type": "text/html; charset=utf-8"},
text="<!DOCTYPE html><html></html>",
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.BadRequestError("bad request", response=response, body=None)
message = _format_openai_error(exc)
assert message == "AI 服务请求失败(400): 请求参数无效, 请检查模型名称和上下文长度"
def test_is_temperature_rejected_matches_moonshot_message():
"""Moonshot 对 reasoning 模型报 'only 1 is allowed for this model'"""
response = httpx.Response(
400,
json={"error": {"message": "invalid temperature: only 1 is allowed for this model"}},
request=httpx.Request("POST", "https://api.moonshot.cn/v1/chat/completions"),
)
exc = openai.BadRequestError(
"bad request",
response=response,
body={"error": {"message": "invalid temperature: only 1 is allowed for this model"}},
)
assert _is_temperature_rejected(exc) is True
def test_is_temperature_rejected_matches_generic_temperature_hint():
response = httpx.Response(
400,
json={"error": {"message": "unsupported parameter: temperature"}},
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.BadRequestError(
"bad request", response=response,
body={"error": {"message": "unsupported parameter: temperature"}},
)
assert _is_temperature_rejected(exc) is True
def test_is_temperature_rejected_false_for_other_400():
"""非 temperature 相关的 400 (如 model not found) 不应触发去 temperature 重试。"""
response = httpx.Response(
400,
json={"error": {"message": "model not found"}},
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.BadRequestError(
"bad request", response=response,
body={"error": {"message": "model not found"}},
)
assert _is_temperature_rejected(exc) is False
def test_is_temperature_rejected_false_for_non_400():
response = httpx.Response(
401,
json={"error": {"message": "invalid api key"}},
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.AuthenticationError("unauthorized", response=response, body=None)
assert _is_temperature_rejected(exc) is False
@@ -8,7 +8,7 @@ import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 轮询间隔: Pro 10~120s / Expert 3~120s
* - 盘后定版时间: 15:01~18:00, 默认 15:02
* - disabled 时(监控关闭)输入框禁用
*/
@@ -20,9 +20,9 @@ export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 120 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const interval = prefs.data?.depth_polling_interval ?? 10
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
+2 -2
View File
@@ -651,8 +651,8 @@ export function Data() {
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 6}
intervalMin={quoteInterval.data?.min_interval ?? 6}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
+1 -1
View File
@@ -28,7 +28,7 @@ const PRESETS: { label: string; provider?: string; url: string; model: string; c
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.7-code', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
{ label: '炸鸡中转站', url: 'https://api.zhaji.dev/v1', model: 'gpt-5.5', website: 'https://api.zhaji.dev', websiteLabel: 'api.zhaji.dev', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.02倍率' },
]
+2 -2
View File
@@ -62,8 +62,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const isTrading = quoteStatus?.is_trading_hours ?? false
// 管道/数据修正运行期间实时行情被临时暂停 — 此时禁止开启
const isPaused = quoteStatus?.paused ?? false
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
const interval = intervalData?.interval ?? 6
const minInterval = intervalData?.min_interval ?? 6
const maxInterval = intervalData?.max_interval ?? 60
const [intervalDraft, setIntervalDraft] = useState(interval)
const feishuWebhookUrl = prefs?.feishu_webhook_url ?? ''