diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index cfe16e8..fecd211 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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(), diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index cef3ae5..301488d 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -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}" diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index 89ff9b2..debe045 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -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) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index a05eb57..84cdbea 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -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: diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 09e4054..edac2fc 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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: diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index a40cf49..c73b3d1 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -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="", + 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 diff --git a/frontend/src/components/data/DepthConfigCard.tsx b/frontend/src/components/data/DepthConfigCard.tsx index a058515..1fed580 100644 --- a/frontend/src/components/data/DepthConfigCard.tsx +++ b/frontend/src/components/data/DepthConfigCard.tsx @@ -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))) diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index f13e18a..1f205c5 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -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)} diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index 7d18693..a674ef9 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -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倍率' }, ] diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 1fdaf29..a65dd49 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -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 ?? ''