fix(config): 收敛 Vite 配置并兼容 AI 可选参数

- 移除重复的 Vite JS 配置并阻止 TypeScript 构建重新生成
- 精确识别 temperature 与 reasoning_effort 的 400 错误并有界降级
- 补充 Docker 只读挂载 .env 的安全边界说明
This commit is contained in:
Arepeater
2026-08-08 15:22:05 +08:00
parent fec53bb46f
commit e1c6aa2a14
5 changed files with 87 additions and 88 deletions
+60 -31
View File
@@ -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:
+24 -1
View File
@@ -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 重试。"""
+1 -1
View File
@@ -88,7 +88,7 @@ AUTH_PASSWORD='你的密码' # 至少 6 位;仅首次生效,已设过则不覆
```
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署可通过此环境变量预置首个密码。
密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;Docker 启动时也会只读挂载原始 `.env`,兼容已有的未加引号配置
密码建议使用单引号包裹,Docker 启动时会把整个原始 `.env` 只读挂载到容器内 `/app/.env`,兼容已有的未加引号配置。容器可以读取其中的密钥但不能修改该文件,请保持主机文件权限为 `600` 并仅运行可信镜像
详细步骤、SSH 转发方案、重置密码方法见 [deployment.md → 访问密码设置](./deployment.md#访问密码设置公网部署必读)。
+2 -1
View File
@@ -8,7 +8,8 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"composite": true
"composite": true,
"emitDeclarationOnly": true
},
"include": ["vite.config.ts"]
}
-54
View File
@@ -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';
}
},
},
},
},
});