Files
tick-stock-panel/backend/tests/test_ai_provider.py
T
shy3130 0ee3aa8f1f fix(ai): 分析类调用放开 max_tokens 限制 — 修复推理模型正文 0 字失败
根因(实测钉死): deepseek-v4-pro 等推理模型把思考 token 计入
max_tokens 预算。个股分析真实调用(62KB prompt, max_tokens=4500):
usage completion=4500 全是 reasoning, finish=length, 正文 0 字,
流"正常"结束 → 前端兜底报「分析未返回内容」→ 表现为"经常失败"
(推理长度随机, 逼近 4500 即失败)。与 180s 超时无关(实际 65s 结束)。

改动:
- ai_provider: generate/stream 的 max_tokens 支持 None=不传该参数,
  输出上限交给服务端默认; _openai_kwargs None 时省略; codex 提示词
  None 时跳过长度约束行
- 四个分析器(个股/复盘/财务/概念轮动)改传 max_tokens=None;
  新增空正文守卫: 流结束但 0 个 delta → 明确报错+日志(原先静默,
  backend.log 无痕迹无法排查)。策略标题生成(max_tokens=8)等小任务
  保持原限制

对照实测(真实 62KB prompt): 不传 → finish=stop, 推理 3311 token
+正文 2407 字, 59s; max_tokens=16000 → 同样成功但推理撒欢 6239
token/93s — 不限制即最优。

验证: 新增 kwargs/codex 单测, ai_provider 21 通过, 相关 174 通过;
线上接口复测: 修复前 0 delta 直接 done, 修复后 1682 delta/2467 字
正常流式; ruff 相对 HEAD 无新增。
2026-08-17 17:47:46 +08:00

369 lines
14 KiB
Python

from __future__ import annotations
import tomllib
import httpx
import openai
from app import secrets_store
from app.api import settings as settings_api
from app.config import settings
from app.services import ai_provider
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():
assert normalize_openai_base_url("http://ai.zedbox.cn:8080") == "http://ai.zedbox.cn:8080/v1"
def test_normalize_openai_base_url_preserves_v1_base():
assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1") == "http://ai.zedbox.cn:8080/v1"
def test_normalize_openai_base_url_strips_chat_completions_path():
assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1/chat/completions") == "http://ai.zedbox.cn:8080/v1"
def test_normalize_openai_base_url_preserves_glm_v4():
"""智谱 GLM 用 /api/paas/v4, 不能强制补成 /v4/v1 (会 404)。"""
assert normalize_openai_base_url("https://open.bigmodel.cn/api/paas/v4") == "https://open.bigmodel.cn/api/paas/v4"
def test_normalize_openai_base_url_strips_chat_completions_from_glm_v4():
"""用户填完整 /v4/chat/completions 时, 去掉后缀归一化为 /v4。"""
assert normalize_openai_base_url("https://open.bigmodel.cn/api/paas/v4/chat/completions") == "https://open.bigmodel.cn/api/paas/v4"
def test_normalize_openai_base_url_preserves_other_version_segments():
"""其它非 v1 版本号 (/v2 等) 也应保持原样。"""
assert normalize_openai_base_url("https://example.com/api/v2") == "https://example.com/api/v2"
def test_normalize_openai_base_url_strips_trailing_slash():
assert normalize_openai_base_url("https://open.bigmodel.cn/api/paas/v4/") == "https://open.bigmodel.cn/api/paas/v4"
def test_format_openai_error_hides_html_gateway_body():
response = httpx.Response(
504,
headers={"content-type": "text/html; charset=utf-8"},
text="<!DOCTYPE html><html><body><h1>Gateway Timeout</h1></body></html>",
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.InternalServerError("gateway timeout", response=response, body=response.text)
message = _format_openai_error(exc)
assert message == "AI 服务请求失败(504): AI 上游服务超时, 请稍后重试或检查 AI Base URL / 网络"
assert "html" not in message.lower()
assert "Gateway Timeout" not in message
def test_format_openai_error_prefers_upstream_detail_when_available():
"""有可读的上游 detail 时优先透出, 而不是用 400 通用文案吞掉。"""
response = httpx.Response(
400,
json={"error": {"message": "model context length exceeded"}},
request=httpx.Request("POST", "https://example.com/v1/chat/completions"),
)
exc = openai.BadRequestError(
"bad request",
response=response,
body={"error": {"message": "model context length exceeded"}},
)
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_optional_openai_params_use_targeted_400_fallbacks():
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
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 重试。"""
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
def test_openai_kwargs_include_configured_reasoning_effort(monkeypatch):
stored = {"ai_provider": "openai_compat"}
monkeypatch.setattr(secrets_store, "load", lambda: stored)
assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000)
stored["ai_provider"] = "openai"
assert ai_provider._openai_kwargs(temperature=None, max_tokens=1000)["reasoning_effort"] == "high"
stored["ai_reasoning_effort"] = "custom-high"
kwargs = ai_provider._openai_kwargs(temperature=0.3, max_tokens=1000)
assert kwargs == {
"max_tokens": 1000,
"temperature": 0.3,
"reasoning_effort": "custom-high",
}
stored["ai_reasoning_effort"] = ""
assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000)
stored["ai_reasoning_effort"] = "custom-high"
stored["ai_provider"] = "openai_compat"
assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000)
def test_openai_kwargs_none_max_tokens_omits_limit():
"""max_tokens=None → 不传上限(推理模型思考 token 计入预算, 分析类调用放开)。"""
kwargs = ai_provider._openai_kwargs(temperature=0.5, max_tokens=None)
assert "max_tokens" not in kwargs
assert kwargs.get("temperature") == 0.5
# 显式数值仍正常下发(策略标题生成等小任务依赖)
assert ai_provider._openai_kwargs(temperature=None, max_tokens=8) == {"max_tokens": 8}
def test_codex_prompt_none_max_tokens_skips_length_hint():
prompt = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=None)
assert "Keep the final answer" not in prompt
bounded = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=300)
assert "Keep the final answer" in bounded
def test_ai_settings_keep_provider_models_separate(monkeypatch):
stored = {
"ai_provider": "openai_compat",
"ai_model": "custom-api-model",
}
def save(updates: dict) -> dict:
stored.update(updates)
return stored
def clear(*keys: str) -> dict:
for key in keys:
stored.pop(key, None)
return stored
monkeypatch.setattr(secrets_store, "load", lambda: stored)
monkeypatch.setattr(secrets_store, "save", save)
monkeypatch.setattr(secrets_store, "clear", clear)
monkeypatch.setattr(ai_provider, "ai_configured", lambda provider=None: True)
monkeypatch.setattr(settings, "ai_provider", "openai_compat")
monkeypatch.setattr(settings, "ai_base_url", "")
monkeypatch.setattr(settings, "ai_model", "")
monkeypatch.setattr(settings, "ai_codex_command", "codex")
monkeypatch.setattr(settings, "ai_codex_reasoning_effort", "")
monkeypatch.setattr(settings, "ai_user_agent", "")
settings_api.save_ai_settings(
settings_api.AiSettingsIn(
provider="codex_cli",
model="gpt-5.6-sol",
codex_command="codex",
codex_reasoning_effort="high",
)
)
assert stored["ai_model"] == "custom-api-model"
assert stored["ai_codex_model"] == "gpt-5.6-sol"
settings_api.save_ai_settings(
settings_api.AiSettingsIn(
provider="openai",
base_url="https://api.openai.com/v1",
model="openai-model",
reasoning_effort="vendor-high",
)
)
assert stored["ai_model"] == "openai-model"
assert stored["ai_reasoning_effort"] == "vendor-high"
assert stored["ai_codex_model"] == "gpt-5.6-sol"
settings_api.save_ai_settings(
settings_api.AiSettingsIn(
provider="openai_compat",
base_url="https://example.com/v1",
model="new-custom-model",
)
)
assert stored["ai_model"] == "new-custom-model"
assert stored["ai_reasoning_effort"] == "vendor-high"
assert stored["ai_codex_model"] == "gpt-5.6-sol"
def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path):
monkeypatch.setenv("PATH", "test-path")
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example")
monkeypatch.setenv("TICKFLOW_API_KEY", "tickflow-secret")
monkeypatch.setenv("AI_API_KEY", "ai-secret")
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
monkeypatch.setenv("AUTH_PASSWORD", "password-secret")
env = ai_provider._codex_process_env(tmp_path / "codex-home")
assert env["PATH"] == "test-path"
assert env["HTTPS_PROXY"] == "http://proxy.example"
assert env["NO_COLOR"] == "1"
assert env["CODEX_HOME"] == str(tmp_path / "codex-home")
assert "TICKFLOW_API_KEY" not in env
assert "AI_API_KEY" not in env
assert "OPENAI_API_KEY" not in env
assert "AUTH_PASSWORD" not in env
def test_codex_config_adapts_local_access_provider_for_docker(monkeypatch, tmp_path):
monkeypatch.setenv("CODEX_DOCKER_HOST", "host.docker.internal")
monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "")
monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "")
monkeypatch.setattr(
ai_provider,
"_read_codex_config",
lambda: {
"model_provider": "codex_local_access",
"model": "gpt-5.6-sol",
"model_providers": {
"codex_local_access": {
"name": "Codex API Service",
"base_url": "http://localhost:62678/v1",
"wire_api": "responses",
"requires_openai_auth": True,
"supports_websockets": False,
"experimental_bearer_token": "local-secret",
}
},
},
)
path = tmp_path / "config.toml"
ai_provider._write_compatible_codex_config(path)
with path.open("rb") as f:
config = tomllib.load(f)
assert config["model_provider"] == "codex_local_access"
provider = config["model_providers"]["codex_local_access"]
assert provider["base_url"] == "http://host.docker.internal:62678/v1"
assert provider["experimental_bearer_token"] == "local-secret"
assert provider["requires_openai_auth"] is True
assert provider["supports_websockets"] is False
def test_codex_config_preserves_remote_provider_without_docker_rewrite(monkeypatch, tmp_path):
monkeypatch.delenv("CODEX_DOCKER_HOST", raising=False)
monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "")
monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "")
monkeypatch.setattr(
ai_provider,
"_read_codex_config",
lambda: {
"model_provider": "remote-api",
"openai_base_url": "https://builtin.example/v1",
"model_providers": {
"remote-api": {
"base_url": "https://custom.example/v1",
"wire_api": "responses",
"requires_openai_auth": True,
}
},
},
)
path = tmp_path / "config.toml"
ai_provider._write_compatible_codex_config(path)
with path.open("rb") as f:
config = tomllib.load(f)
assert config["model_provider"] == "remote-api"
assert config["openai_base_url"] == "https://builtin.example/v1"
provider = config["model_providers"]["remote-api"]
assert provider["base_url"] == "https://custom.example/v1"
assert provider["wire_api"] == "responses"
assert provider["requires_openai_auth"] is True