diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 925d919..754a485 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -269,23 +269,20 @@ async def _run_openai_once( client = _openai_client(ai_key, timeout) model = current_ai_model() req_messages = list(messages) - try: - resp = await client.chat.completions.create( - model=model, - messages=req_messages, - **_openai_kwargs(temperature=temperature, max_tokens=max_tokens), - ) - except Exception as exc: - # 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): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: resp = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, ) - else: + break + except Exception as exc: + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -315,23 +312,22 @@ async def _stream_openai( 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): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: stream = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, stream=True, ) - else: + break + except Exception as exc: + # 流尚未开始 yield, 可安全移除被拒绝的可选参数后重建。 + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -358,11 +354,10 @@ 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") +# 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测, +# 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。 +_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed") +_REASONING_EFFORT_REJECT_HINTS = ("reasoning_effort", "reasoning effort") def _is_temperature_rejected(exc: Exception) -> bool: @@ -370,7 +365,41 @@ def _is_temperature_rejected(exc: Exception) -> bool: 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) + return _openai_error_param(exc) == "temperature" or any( + h in text.lower() for h in _TEMP_REJECT_HINTS + ) + + +def _is_reasoning_effort_rejected(exc: Exception) -> bool: + """True if the upstream 400 specifically rejects reasoning_effort.""" + if getattr(exc, "status_code", None) != 400: + return False + text = _openai_error_detail(exc) or str(exc) + return _openai_error_param(exc) == "reasoning_effort" or any( + h in text.lower() for h in _REASONING_EFFORT_REJECT_HINTS + ) + + +def _openai_error_param(exc: Exception) -> str: + body = getattr(exc, "body", None) + if not isinstance(body, dict): + return "" + error = body.get("error") + if isinstance(error, dict): + body = error + return str(body.get("param") or "").strip().lower() + + +def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None: + """Remove one explicitly rejected optional argument for a bounded retry.""" + retry_kwargs = dict(kwargs) + if "temperature" in retry_kwargs and _is_temperature_rejected(exc): + retry_kwargs.pop("temperature") + return retry_kwargs + if "reasoning_effort" in retry_kwargs and _is_reasoning_effort_rejected(exc): + retry_kwargs.pop("reasoning_effort") + return retry_kwargs + return None def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict: diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index e7cf262..75930fb 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -111,7 +111,7 @@ def test_is_temperature_rejected_matches_moonshot_message(): assert _is_temperature_rejected(exc) is True -def test_is_temperature_rejected_matches_generic_temperature_hint(): +def test_optional_openai_params_use_targeted_400_fallbacks(): response = httpx.Response( 400, json={"error": {"message": "unsupported parameter: temperature"}}, @@ -123,6 +123,29 @@ def test_is_temperature_rejected_matches_generic_temperature_hint(): ) assert _is_temperature_rejected(exc) is True + kwargs = {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "reasoning_effort": "high", + } + + response = httpx.Response( + 400, + json={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + exc = openai.BadRequestError( + "bad request", response=response, + body={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + ) + assert _is_temperature_rejected(exc) is False + assert ai_provider._is_reasoning_effort_rejected(exc) is True + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "temperature": 0.3, + } + assert kwargs == {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + def test_is_temperature_rejected_false_for_other_400(): """非 temperature 相关的 400 (如 model not found) 不应触发去 temperature 重试。""" diff --git a/docs/configuration.md b/docs/configuration.md index 21c38ad..65546a8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,7 +88,7 @@ AUTH_PASSWORD='你的密码' # 至少 6 位;仅首次生效,已设过则不覆 ``` 面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署可通过此环境变量预置首个密码。 -密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;Docker 启动时也会只读挂载原始 `.env`,兼容已有的未加引号配置。 +密码建议使用单引号包裹,Docker 启动时会把整个原始 `.env` 只读挂载到容器内 `/app/.env`,兼容已有的未加引号配置。容器可以读取其中的密钥但不能修改该文件,请保持主机文件权限为 `600` 并仅运行可信镜像。 详细步骤、SSH 转发方案、重置密码方法见 [deployment.md → 访问密码设置](./deployment.md#访问密码设置公网部署必读)。 diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index ba173d2..870aa8d 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -8,7 +8,8 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true, - "composite": true + "composite": true, + "emitDeclarationOnly": true }, "include": ["vite.config.ts"] } diff --git a/frontend/vite.config.js b/frontend/vite.config.js deleted file mode 100644 index 239f479..0000000 --- a/frontend/vite.config.js +++ /dev/null @@ -1,54 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'node:path'; -const backendHost = process.env.BACKEND_HOST || '127.0.0.1'; -const proxyHost = ['0.0.0.0', '::'].includes(backendHost) ? '127.0.0.1' : backendHost; -const backendPort = process.env.BACKEND_PORT || '3018'; -const backendTarget = `http://${proxyHost}:${backendPort}`; -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, - server: { - host: '0.0.0.0', // dev.sh / dev.ps1 会用 CLI --host 覆盖 - port: 3011, - proxy: { - // dev 时 /api 转发到与启动脚本相同的 FastAPI 地址 - '/api': { - target: backendTarget, - // SSE 端点需要禁用缓冲 - configure: (proxy) => { - proxy.on('proxyReq', (_proxyReq, req) => { - if (req.url?.includes('/stream')) { - _proxyReq.setHeader('Accept', 'text/event-stream'); - _proxyReq.setHeader('Cache-Control', 'no-cache'); - _proxyReq.setHeader('Connection', 'keep-alive'); - } - }); - }, - }, - '/health': backendTarget, - }, - }, - build: { - outDir: 'dist', - sourcemap: false, - rollupOptions: { - output: { - // 把重型图表库拆到独立 chunk, 避免打进主包 + 让页面按需加载。 - // 用函数形式按 node_modules 路径匹配, 比对象形式更可靠。 - manualChunks(id) { - if (id.includes('node_modules')) { - if (id.includes('echarts')) - return 'echarts'; - if (id.includes('lightweight-charts')) - return 'lightweight-charts'; - } - }, - }, - }, - }, -});