mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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(),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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倍率' },
|
||||
]
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
Reference in New Issue
Block a user