Files
kefu/wechat_rpa/test_backend.py
T
2026-08-27 14:04:28 +08:00

1084 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""配置后台与桌面端同步的本地闭环测试。"""
from __future__ import annotations
import json
import hashlib
import shutil
import model_protocol
import socket
import sqlite3
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from unittest import mock
from pathlib import Path
from test_support import local_state_redirect
import admin_backend
import app_version
import backend_client
class BackendIntegrationTest(unittest.TestCase):
def test_development_diagnostics_are_cloud_controlled_and_redacted(self) -> None:
config = {
"AI_DEVELOPMENT_MODE": True,
"AI_PROVIDER_TYPE": "openai",
"AI_API_BASE": "https://api.example/v1",
"AI_API_KEY": "top-secret-key",
"AI_MODEL": "test-model",
"AI_MCP_SERVERS": [
{
"headers": {"Authorization": "Bearer hidden-auth"},
"env": {"ACCESS_TOKEN": "hidden-token"},
"args": [
"--token",
"hidden-argument",
"--password=hidden-inline",
"Bearer hidden-bearer",
],
}
],
}
response = {"version": 8, "updated_at": "now"}
diagnostics = backend_client._config_diagnostics(
config,
response,
"https://cloud.example/api/v1/desktop/config?api_key=query-secret",
)
rendered = "\n".join(diagnostics)
self.assertIn("https://cloud.example/api/v1/desktop/config", rendered)
self.assertIn("云端配置版本: v8", rendered)
self.assertIn('"AI_MODEL": "test-model"', rendered)
for secret in (
"top-secret-key",
"hidden-auth",
"hidden-token",
"hidden-argument",
"hidden-inline",
"hidden-bearer",
"query-secret",
):
self.assertNotIn(secret, rendered)
self.assertEqual(
backend_client._config_diagnostics(
{**config, "AI_DEVELOPMENT_MODE": False}, response, "https://cloud.example/config"
),
[],
)
def test_model_request_diagnostics_never_print_the_api_key(self) -> None:
import ai_chat
import ai_config
previous = {
"AI_DEVELOPMENT_MODE": getattr(ai_config, "AI_DEVELOPMENT_MODE", False),
"AI_API_BASE": ai_config.AI_API_BASE,
"AI_API_KEY": ai_config.AI_API_KEY,
"AI_MODEL": ai_config.AI_MODEL,
}
try:
ai_config.AI_DEVELOPMENT_MODE = True
ai_config.AI_API_BASE = "https://api.example/v1"
ai_config.AI_API_KEY = "never-print-this-key"
ai_config.AI_MODEL = "diagnostic-model"
with mock.patch("builtins.print") as printer:
ai_chat._log_request_diagnostics(
"https://api.example/v1/chat/completions", "OpenAI 兼容"
)
rendered = "\n".join(
" ".join(str(item) for item in call.args)
for call in printer.call_args_list
)
self.assertIn("https://api.example/v1/chat/completions", rendered)
self.assertIn("diagnostic-model", rendered)
self.assertNotIn("never-print-this-key", rendered)
finally:
for key, value in previous.items():
setattr(ai_config, key, value)
def test_model_connection_uses_unsaved_values_and_saved_key(self) -> None:
current = {
"AI_API_BASE": "https://saved.example/v1",
"AI_API_KEY": "saved-secret",
"AI_MODEL": "saved-model",
"AI_TIMEOUT": 120,
}
config = admin_backend.model_test_config(
{
"AI_API_BASE": "https://new.example/v1",
"AI_API_KEY": "",
"AI_MODEL": "new-model",
"AI_TIMEOUT": "180",
},
current,
)
self.assertEqual(config["endpoint"], "https://new.example/v1/chat/completions")
self.assertEqual(config["api_key"], "saved-secret")
self.assertEqual(config["model"], "new-model")
self.assertEqual(config["timeout"], 60)
def test_model_connection_success_does_not_expose_key(self) -> None:
config = admin_backend.model_test_config(
{
"AI_API_BASE": "https://api.example/v1",
"AI_API_KEY": "top-secret-key",
"AI_MODEL": "test-model",
"AI_TIMEOUT": 10,
},
{},
)
response = json.dumps(
{"choices": [{"message": {"content": "OK"}}]}
).encode("utf-8")
with mock.patch(
"admin_backend._perform_http_request", return_value=(200, response)
) as call:
result = admin_backend.test_model_connection(config)
self.assertTrue(result["ok"])
self.assertEqual(result["http_status"], 200)
self.assertNotIn("top-secret-key", json.dumps(result, ensure_ascii=False))
self.assertEqual(call.call_args.args[0], config["endpoint"])
self.assertEqual(
call.call_args.kwargs["headers"]["Authorization"], "Bearer top-secret-key"
)
payload = call.call_args.kwargs["payload"]
self.assertEqual(payload["model"], "test-model")
def test_model_connection_error_redacts_key(self) -> None:
config = admin_backend.model_test_config(
{
"AI_API_BASE": "https://api.example/v1",
"AI_API_KEY": "top-secret-key",
"AI_MODEL": "test-model",
"AI_TIMEOUT": 10,
},
{},
)
response = json.dumps(
{"error": {"message": "invalid top-secret-key"}}
).encode("utf-8")
with mock.patch(
"admin_backend._perform_http_request", return_value=(401, response)
):
result = admin_backend.test_model_connection(config)
self.assertFalse(result["ok"])
self.assertEqual(result["http_status"], 401)
self.assertIn("API Key 无效", result["message"])
self.assertNotIn("top-secret-key", json.dumps(result, ensure_ascii=False))
def test_dify_and_comfyui_use_provider_specific_endpoints(self) -> None:
dify = admin_backend.model_test_config(
{
"AI_PROVIDER_TYPE": "dify",
"AI_API_BASE": "https://dify.example/v1",
"AI_API_KEY": "app-secret",
"AI_MODEL": "",
"AI_TIMEOUT": 10,
},
{},
)
self.assertEqual(dify["endpoint"], "https://dify.example/v1/chat-messages")
self.assertEqual(dify["provider_type"], "dify")
comfyui = admin_backend.model_test_config(
{
"AI_PROVIDER_TYPE": "comfyui",
"AI_API_BASE": "http://127.0.0.1:8188",
"AI_API_KEY": "",
"AI_MODEL": "",
"AI_TIMEOUT": 10,
},
{},
)
self.assertEqual(comfyui["endpoint"], "http://127.0.0.1:8188/system_stats")
with mock.patch(
"admin_backend._perform_http_request",
return_value=(200, b'{"system": {"os": "windows"}, "devices": []}'),
) as call:
result = admin_backend.test_model_connection(comfyui)
self.assertTrue(result["ok"])
self.assertEqual(call.call_args.kwargs["method"], "GET")
self.assertIsNone(call.call_args.kwargs["payload"])
def test_desktop_ai_respects_explicit_dify_provider(self) -> None:
import ai_chat
import ai_config
old_provider = ai_config.AI_PROVIDER_TYPE
old_base = ai_config.AI_API_BASE
try:
ai_config.AI_PROVIDER_TYPE = "dify"
ai_config.AI_API_BASE = "https://dify.example/v1"
self.assertTrue(ai_chat._is_dify_endpoint())
self.assertEqual(
ai_chat._completions_url(),
"https://dify.example/v1/chat-messages",
)
ai_config.AI_PROVIDER_TYPE = "openai"
self.assertFalse(ai_chat._is_dify_endpoint())
finally:
ai_config.AI_PROVIDER_TYPE = old_provider
ai_config.AI_API_BASE = old_base
def test_release_status_detects_optional_and_forced_updates(self) -> None:
optional = app_version.release_status(
{"latest_version": "1.0.1", "force_upgrade": False}
)
self.assertTrue(optional["update_available"])
self.assertFalse(optional["force_upgrade"])
forced = app_version.release_status(
{"latest_version": "1.0.1", "force_upgrade": True}
)
self.assertTrue(forced["force_upgrade"])
current = app_version.release_status(
{"latest_version": app_version.APP_VERSION, "force_upgrade": True}
)
self.assertFalse(current["update_available"])
self.assertFalse(current["force_upgrade"])
def test_release_form_validation(self) -> None:
release = admin_backend.validate_release_form(
{
"latest_version": "v1.2.3",
"download_url": "https://example.com/client.exe",
"release_notes": "修复已知问题",
"force_upgrade": "1",
}
)
self.assertEqual(release["latest_version"], "1.2.3")
self.assertTrue(release["force_upgrade"])
with self.assertRaises(ValueError):
admin_backend.validate_release_form(
{"latest_version": "1.2", "force_upgrade": "1"}
)
with self.assertRaises(ValueError):
admin_backend.validate_release_form(
{"latest_version": "1.2.3", "force_upgrade": "1"}
)
def test_desktop_sync_key_matches_server(self) -> None:
self.assertEqual(
backend_client.DESKTOP_SYNC_KEY,
admin_backend.DEFAULT_DESKTOP_SYNC_KEY,
)
def test_pbkdf2_fallback_matches_standard_library(self) -> None:
password = b"FallbackPassword123"
salt = b"0123456789abcdef"
expected = hashlib.pbkdf2_hmac("sha256", password, salt, 1_000)
native_pbkdf2 = admin_backend.hashlib.pbkdf2_hmac
try:
admin_backend.hashlib.pbkdf2_hmac = None
actual = admin_backend.pbkdf2_sha256(password, salt, 1_000)
finally:
admin_backend.hashlib.pbkdf2_hmac = native_pbkdf2
self.assertEqual(actual, expected)
def test_stale_runtime_file_is_ignored(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
runtime_file = root / "backend_runtime.json"
runtime_file.write_text(
json.dumps(
{
"pid": 0,
"host": "127.0.0.1",
"port": 8765,
"server_url": "http://127.0.0.1:8765",
"local_sync_token": "stale-token",
}
),
encoding="utf-8",
)
old_runtime_file = backend_client.RUNTIME_FILE
try:
backend_client.RUNTIME_FILE = runtime_file
self.assertEqual(backend_client.discover_local_runtime(), {})
self.assertEqual(backend_client.discover_local_sync_token(), "")
finally:
backend_client.RUNTIME_FILE = old_runtime_file
def test_local_agent_name_overrides_cloud_and_blank_follows_cloud(self) -> None:
import ai_config
old_agent = ai_config.AI_AGENT_NAME
old_cloud = ai_config.AI_CLOUD_AGENT_NAME
old_local = ai_config.AI_LOCAL_AGENT_NAME
old_prompt = ai_config.AI_SYSTEM_PROMPT
try:
ai_config.AI_CLOUD_AGENT_NAME = "旧云端客服"
ai_config.AI_LOCAL_AGENT_NAME = ""
ai_config.AI_AGENT_NAME = "旧云端客服"
ai_config.set_local_agent_name_override("本机客服", persist=False)
ai_config.apply_settings({"AI_AGENT_NAME": "新云端客服"}, persist=False)
self.assertEqual(ai_config.AI_CLOUD_AGENT_NAME, "新云端客服")
self.assertEqual(ai_config.AI_AGENT_NAME, "本机客服")
ai_config.set_local_agent_name_override("", persist=False)
self.assertEqual(ai_config.AI_AGENT_NAME, "新云端客服")
finally:
ai_config.AI_AGENT_NAME = old_agent
ai_config.AI_CLOUD_AGENT_NAME = old_cloud
ai_config.AI_LOCAL_AGENT_NAME = old_local
ai_config.AI_SYSTEM_PROMPT = old_prompt
class DesktopConfigFallbackTest(unittest.TestCase):
"""桌面端拉配置:新接口优先,老接口兜底。
客户端和服务端是分批更新的,两边版本对不齐是常态而不是意外。直接把路径改
成 v2,那些服务端还没升级的部署会当场同步失败——而且失败得很安静,界面上
只是配置一直显示旧的,没人会立刻发现。
"""
def _fail(self, message: str):
return backend_client.BackendError(message)
def test_the_new_endpoint_is_tried_first(self) -> None:
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
return 200, {"config": {}, "version": 3}
with mock.patch.object(backend_client, "_request", fake):
response, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
self.assertEqual(calls, ["/api/v2/desktop/config"])
self.assertEqual(used, "/api/v2/desktop/config")
self.assertEqual(response["version"], 3)
def test_a_404_falls_back_to_the_legacy_endpoint(self) -> None:
"""服务端还是老版本时,必须照常同步,不能报错。"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
if path.startswith("/api/v2"):
raise backend_client.BackendError("HTTP 404", status=404)
return 200, {"config": {}, "version": 7}
with mock.patch.object(backend_client, "_request", fake):
response, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
self.assertEqual(calls, ["/api/v2/desktop/config", "/api/v1/desktop/config"])
self.assertEqual(used, "/api/v1/desktop/config")
self.assertEqual(response["version"], 7)
def test_the_fallback_is_decided_by_status_not_by_wording(self) -> None:
"""老后台对未知 GET 路径回的是"页面不存在",未知 POST 才回"接口不存在"。
一个字之差。曾经这里靠文案匹配,结果对着真的老后台跑就整个失效了——
判据必须是状态码。
"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
if path.startswith("/api/v2"):
raise backend_client.BackendError("页面不存在", status=404)
return 200, {"config": {}}
with mock.patch.object(backend_client, "_request", fake):
_, used = backend_client._fetch_desktop_config("http://x", "k", 5.0)
self.assertEqual(used, "/api/v1/desktop/config")
def test_a_connection_failure_is_not_retried_on_the_old_path(self) -> None:
"""连不上就是连不上,换个路径同样连不上。
真正的危害不是多发一个请求,而是把"服务器宕了"这个明确故障,伪装成
"接口不存在"往下走——最后一层抛出来的是老接口的错误,人会去查一个根本
没坏的地方。
"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
# 连不上根本没拿到响应,所以没有状态码
raise backend_client.BackendError("无法连接后台:timed out")
with mock.patch.object(backend_client, "_request", fake):
with self.assertRaises(backend_client.BackendError) as caught:
backend_client._fetch_desktop_config("http://x", "k", 5.0)
self.assertEqual(calls, ["/api/v2/desktop/config"], "不该退回去重试")
self.assertIn("无法连接", str(caught.exception))
def test_a_bad_sync_key_surfaces_instead_of_falling_back(self) -> None:
"""401/403 说明服务在、只是凭证不对。这是要人去改配置的错误,不能吞。"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
raise backend_client.AuthenticationError("同步凭证无效", status=401)
with mock.patch.object(backend_client, "_request", fake):
with self.assertRaises(backend_client.AuthenticationError):
backend_client._fetch_desktop_config("http://x", "k", 5.0)
self.assertEqual(calls, ["/api/v2/desktop/config"])
def test_a_404_on_both_paths_reports_the_last_error(self) -> None:
def fake(method, server, path, **kwargs):
raise backend_client.BackendError("HTTP 404", status=404)
with mock.patch.object(backend_client, "_request", fake):
with self.assertRaises(backend_client.BackendError):
backend_client._fetch_desktop_config("http://x", "k", 5.0)
def test_the_legacy_constant_still_points_at_a_real_path(self) -> None:
"""DESKTOP_CONFIG_PATH 可能被别处引用,不能变成空字符串。"""
self.assertIn(backend_client.DESKTOP_CONFIG_PATH, backend_client.DESKTOP_CONFIG_PATHS)
self.assertTrue(backend_client.DESKTOP_CONFIG_PATH.startswith("/api/"))
class ModelCallReportFallbackTest(unittest.TestCase):
"""调用留痕上报:和拉配置同一套 v2 → v1 顺序。
补这组测试是因为老后台(8765)退役前卡在这一条上:配置同步早有 v2,
上报却只有 v1。桌面端只要改指到 8766,配置照常同步、回复照常发,唯独
调用记录一条都不进库——静悄悄地丢掉的正是出事后用来解释"这句话怎么来的"
那份证据。
"""
def _settings(self):
return {"server_url": "http://x"}
def test_the_new_endpoint_is_tried_first(self) -> None:
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
return 200, {"ok": True}
with (
mock.patch.object(backend_client, "_request", fake),
mock.patch.object(backend_client, "load_settings", self._settings),
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
):
ok = backend_client.report_model_call({"task_id": "t1"})
self.assertTrue(ok)
self.assertEqual(calls, ["/api/v2/model/calls"], "新接口在就不该再打老的")
def test_a_404_falls_back_to_the_legacy_endpoint(self) -> None:
"""还指着老后台(8765)的客户端必须照常上报,不能悄悄丢数据。"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
if path.startswith("/api/v2"):
raise backend_client.BackendError("接口不存在", status=404)
return 200, {"ok": True}
with (
mock.patch.object(backend_client, "_request", fake),
mock.patch.object(backend_client, "load_settings", self._settings),
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
):
ok = backend_client.report_model_call({"task_id": "t1"})
self.assertTrue(ok)
self.assertEqual(calls, ["/api/v2/model/calls", "/api/v1/model/calls"])
def test_a_401_does_not_fall_back(self) -> None:
"""凭证不对换个路径重试同样不对,只会多打一个请求。"""
calls = []
def fake(method, server, path, **kwargs):
calls.append(path)
raise backend_client.AuthenticationError("同步凭证无效", status=401)
with (
mock.patch.object(backend_client, "_request", fake),
mock.patch.object(backend_client, "load_settings", self._settings),
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
):
ok = backend_client.report_model_call({"task_id": "t1"})
self.assertFalse(ok)
self.assertEqual(calls, ["/api/v2/model/calls"])
def test_a_failure_never_raises_into_the_reply_path(self) -> None:
"""上报是观测。它出问题绝不能把已经生成好的回复流程带下水。"""
def boom(*_args, **_kwargs):
raise RuntimeError("断网")
with (
mock.patch.object(backend_client, "_request", boom),
mock.patch.object(backend_client, "load_settings", self._settings),
mock.patch.object(backend_client, "discover_local_sync_token", lambda: ""),
):
self.assertFalse(backend_client.report_model_call({"task_id": "t1"}))
class GatewayUrlDerivationTest(unittest.TestCase):
"""网关地址由后台算出来告诉客户端。
这是"只改一个域名"的关键。桌面端配置里只有后台地址一项,网关在哪、模型怎么
编排全部由后台决定——运维换网关位置只动后台一处,不用挨个改客户端。
"""
def test_direct_port_access_points_at_the_local_gateway_port(self) -> None:
"""本机开发:三个服务各占一个端口,网关就在同机的 8770。"""
self.assertEqual(
admin_backend.derive_gateway_url("", "http", "127.0.0.1:8765"),
"http://127.0.0.1:8770/v1/answer",
)
self.assertEqual(
admin_backend.derive_gateway_url("", "http", "192.168.1.20:8766"),
"http://192.168.1.20:8770/v1/answer",
)
def test_a_proxied_domain_gets_a_same_origin_path(self) -> None:
"""反代后面只有 80/443 对外,公网根本连不到 8770。
这两种推法必须分开。用一套规则套两种部署,总有一边是错的——而且错得很
安静:客户端拿到一个连不上的地址,表现成"模型不回复"。
"""
self.assertEqual(
admin_backend.derive_gateway_url("", "https", "xchat.example.com"),
"https://xchat.example.com/gateway/v1/answer",
)
def test_an_absolute_override_is_used_as_is(self) -> None:
"""网关在别的域名或别的机器上时用这个。"""
self.assertEqual(
admin_backend.derive_gateway_url(
"https://gw.example.com/v1/answer", "https", "xchat.example.com"
),
"https://gw.example.com/v1/answer",
)
def test_a_path_override_hangs_off_the_backend_domain(self) -> None:
self.assertEqual(
admin_backend.derive_gateway_url(
"/model-gw/v1/answer", "https", "xchat.example.com"
),
"https://xchat.example.com/model-gw/v1/answer",
)
def test_a_missing_host_still_yields_something_usable(self) -> None:
"""拿不到 Host 时给本机地址,而不是空串。
空串会让客户端把 gateway 当成"没配",静默回落到本机单模型——而本机
没有密钥,最后报的是一个看不懂的 401。
"""
self.assertTrue(
admin_backend.derive_gateway_url("", "http", "").startswith("http://")
)
def test_an_override_without_a_scheme_is_refused_on_save(self) -> None:
"""漏了 http:// 会被拼成 https://你的域名/gw.example.com/v1/answer。
那是个没意义的地址,而且失败要等到桌面端下次发模型请求才暴露。
"""
base = {
"AI_AGENT_NAME": "甲",
"AI_HOSPITAL_NAME": "乙",
"AI_CONTEXT_MAX_ROUNDS": "5",
"AI_MCP_MAX_ROUNDS": "5",
"AI_MCP_SERVERS": "[]",
}
with self.assertRaises(ValueError) as caught:
admin_backend.validate_config_form(
{**base, "AI_GATEWAY_URL": "gw.example.com/v1/answer"}, {}
)
self.assertIn("http", str(caught.exception))
def test_an_empty_override_is_accepted_and_means_auto(self) -> None:
config = admin_backend.validate_config_form(
{
"AI_AGENT_NAME": "甲",
"AI_HOSPITAL_NAME": "乙",
"AI_CONTEXT_MAX_ROUNDS": "5",
"AI_MCP_MAX_ROUNDS": "5",
"AI_MCP_SERVERS": "[]",
"AI_GATEWAY_URL": "",
},
{},
)
self.assertEqual(config["AI_GATEWAY_URL"], "")
class GatewayArrivesBySyncTest(unittest.TestCase):
"""桌面端不用手工配网关——同步时后台会把地址带过来。"""
def _settings(self, response, existing=None):
"""跑一遍 _apply_config_response,返回它写下的设置。"""
saved = {}
base = backend_client.default_settings()
if existing:
base.update(existing)
with (
mock.patch.object(backend_client, "load_settings", lambda: dict(base)),
mock.patch.object(backend_client, "save_settings", saved.update),
mock.patch("ai_config.apply_settings", lambda cfg, persist=True: cfg),
):
backend_client._apply_config_response(response, dict(base))
return saved
def test_the_synced_gateway_address_lands_in_the_settings(self) -> None:
saved = self._settings(
{
"version": 3,
"config": {},
"gateway": {"enabled": True, "url": "https://x.example.com/gateway/v1/answer"},
}
)
self.assertEqual(
saved["gateway"],
{"enabled": True, "url": "https://x.example.com/gateway/v1/answer"},
)
def test_an_old_backend_without_a_gateway_block_keeps_the_last_one(self) -> None:
"""老版本后端不返回这一段。清空等于把已经能用的客户端打回"没有网关"。"""
existing = {"gateway": {"enabled": True, "url": "https://old.example.com/v1/answer"}}
saved = self._settings({"version": 4, "config": {}}, existing=existing)
self.assertEqual(saved.get("gateway", existing["gateway"]), existing["gateway"])
def test_a_blank_url_from_the_backend_is_ignored(self) -> None:
"""后台配错回了空地址时,宁可保持原样也不要把客户端弄成没网关。"""
existing = {"gateway": {"enabled": True, "url": "https://good.example.com/v1/answer"}}
saved = self._settings(
{"version": 5, "config": {}, "gateway": {"enabled": True, "url": " "}},
existing=existing,
)
self.assertEqual(saved.get("gateway", existing["gateway"]), existing["gateway"])
class EndpointModeTest(unittest.TestCase):
"""接口地址:自动补全 vs 原样使用。
加这个开关是因为 auto 那套拼接规则只覆盖得了"服务商标准形状"。自建服务的路径
常常不按套路,比如 `https://api.example.com/custom/llm/invoke`——auto 会把它
拼成 `.../invoke/chat/completions`,请求发到一个不存在的地址,报 404,而排查的
人会去怀疑密钥和网络。
为什么用显式开关而不是"更聪明的猜测"`https://api.example.com/openai` 到底是
前缀还是完整端点,光看地址分不出来。猜错的两种方向都会静默地把请求发歪。
"""
def test_auto_completes_the_path_for_each_kind(self) -> None:
cases = [
("openai", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"),
("openai", "https://api.deepseek.com", "https://api.deepseek.com/chat/completions"),
("claude", "https://api.anthropic.com", "https://api.anthropic.com/v1/messages"),
("dify", "https://api.dify.ai/v1", "https://api.dify.ai/v1/chat-messages"),
]
for kind, base, expected in cases:
with self.subTest(kind=kind, base=base):
self.assertEqual(model_protocol.endpoint_url(kind, base), expected)
def test_auto_does_not_double_append_a_complete_url(self) -> None:
"""已经写全的标准地址不该再被拼一次。"""
full = "https://api.openai.com/v1/chat/completions"
self.assertEqual(model_protocol.endpoint_url("openai", full), full)
def test_exact_uses_the_address_verbatim(self) -> None:
odd = "https://api.example.com/custom/llm/invoke"
self.assertEqual(model_protocol.endpoint_url("openai", odd), f"{odd}/chat/completions")
self.assertEqual(model_protocol.endpoint_url("openai", odd, "exact"), odd)
def test_exact_wins_for_every_kind(self) -> None:
"""exact 是"别动我的地址",不该被接口类型的规则翻掉。"""
odd = "https://gw.internal/llm"
for kind in ("openai", "claude", "dify", "comfyui"):
with self.subTest(kind=kind):
self.assertEqual(model_protocol.endpoint_url(kind, odd, "exact"), odd)
def test_a_trailing_slash_is_trimmed_in_both_modes(self) -> None:
self.assertEqual(
model_protocol.endpoint_url("openai", "https://x.com/llm/", "exact"),
"https://x.com/llm",
)
def test_the_connectivity_test_uses_the_same_rule_as_the_real_call(self) -> None:
"""两边规则若不一致,测试会去戳一个和实际调用不同的地址。
那是最没用的一种测试:测通了照样用不了,或者反过来。
"""
odd = "https://api.example.com/custom/llm/invoke"
for mode in ("auto", "exact"):
with self.subTest(mode=mode):
self.assertEqual(
admin_backend._model_endpoint(odd, "openai", mode),
model_protocol.endpoint_url("openai", odd, mode),
)
def test_dify_upload_root_follows_the_mode(self) -> None:
"""Dify 传图要拼 /files/upload,根地址得跟着 exact 走。"""
self.assertEqual(
model_protocol.dify_api_root("https://dify.internal/app/chat", "exact"),
"https://dify.internal/app/chat",
)
class EndpointModeStorageTest(unittest.TestCase):
"""存和读。老库要能平滑升级,老记录的行为必须一个字都不变。"""
def setUp(self) -> None:
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.db = admin_backend.Database(self.root / "t.db")
self.db.initialize("Admin@123456")
self.uid = self.db.authenticate("admin", "Admin@123456")["id"]
def _save(self, **extra):
item = {
"id": "p1", "name": "出口", "kind": "openai",
"base_url": "https://api.example.com/custom/llm/invoke",
"api_key": "sk-x", "model": "m",
}
item.update(extra)
return self.db.save_model_provider(item, self.uid, "1.1.1.1")
def test_the_mode_round_trips(self) -> None:
self._save(endpoint_mode="exact")
item = next(x for x in self.db.model_providers() if x["id"] == "p1")
self.assertEqual(item["endpoint_mode"], "exact")
self.assertEqual(item["endpoint"], "https://api.example.com/custom/llm/invoke")
def test_the_default_is_auto(self) -> None:
"""不填就是老行为。这个默认值是整件事零回归的前提。"""
self._save()
item = next(x for x in self.db.model_providers() if x["id"] == "p1")
self.assertEqual(item["endpoint_mode"], "auto")
self.assertTrue(item["endpoint"].endswith("/chat/completions"))
def test_an_unknown_mode_is_refused(self) -> None:
with self.assertRaises(ValueError) as caught:
self._save(endpoint_mode="随便写")
self.assertIn("地址模式", str(caught.exception))
def test_an_old_database_without_the_column_is_upgraded_in_place(self) -> None:
"""已经在用的库不能因为加一列就要重建表——重建的每一步都可能丢数据。"""
path = self.root / "old.db"
con = sqlite3.connect(path)
con.executescript(
"""
CREATE TABLE model_providers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
base_url TEXT NOT NULL, api_key_enc TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '', capabilities TEXT NOT NULL DEFAULT 'text',
max_tokens INTEGER NOT NULL DEFAULT 500, temperature REAL NOT NULL DEFAULT 0.35,
timeout_ms INTEGER NOT NULL DEFAULT 30000, max_inflight INTEGER NOT NULL DEFAULT 32,
rpm_limit INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1,
health TEXT NOT NULL DEFAULT 'unknown', health_checked_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, updated_by INTEGER);
INSERT INTO model_providers (id,name,kind,base_url,created_at,updated_at)
VALUES ('old-1','老出口','openai','https://api.openai.com/v1','x','x');
"""
)
con.commit()
con.close()
with mock.patch("builtins.print"):
admin_backend.Database(path).initialize("Admin@123456")
item = next(
x for x in admin_backend.Database(path).model_providers() if x["id"] == "old-1"
)
self.assertEqual(item["endpoint_mode"], "auto", "老记录必须还是老行为")
self.assertEqual(item["endpoint"], "https://api.openai.com/v1/chat/completions")
def test_running_the_column_migration_twice_is_harmless(self) -> None:
with mock.patch("builtins.print"):
admin_backend.Database(self.root / "t.db").initialize("Admin@123456")
self.assertTrue(self.db.model_providers() is not None)
class ModelCallTraceabilityTest(unittest.TestCase):
"""`model_calls` 存客户原话、模型原话、审核原因——用来"跟踪问题"。
网关和桌面端各自都会往这张表报一次同一次调用(网关看得到候选和裁判,
桌面端才知道审核规则命中原因)。两边共用 task_id,谁先落盘谁建行,后到
的一方只补自己独有的字段——这里测的就是这道合并逻辑不多写也不少写。
"""
def setUp(self) -> None:
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.db = admin_backend.Database(self.root / "t.db")
with mock.patch("builtins.print"):
self.db.initialize("Admin@123456")
def _record(self, **extra) -> dict:
record = {
"device_id": "dev-1",
"task_id": "task-1",
"roles_version": 1,
"judge_mode": "arbitrate",
"chosen": "openai-primary",
"judge": {"winner": "A", "score": 0.8, "risk": "low"},
"candidates": [{"provider": "openai-primary", "text": "建议您注意休息"}],
"total_ms": 900,
"customer_text": "我这几天总是失眠",
"reply_text": "建议您注意休息",
"review_reason": "",
}
record.update(extra)
return record
def _row(self, task_id: str) -> sqlite3.Row:
with self.db.connect() as con:
return con.execute(
"SELECT * FROM model_calls WHERE task_id = ?", (task_id,)
).fetchone()
def test_a_call_round_trips_customer_and_reply_text(self) -> None:
self.db.log_model_call(self._record())
row = self._row("task-1")
self.assertEqual(row["customer_text"], "我这几天总是失眠")
self.assertEqual(row["reply_text"], "建议您注意休息")
self.assertEqual(row["review_reason"], "")
def test_a_second_report_with_the_same_task_id_only_merges_review_reason(self) -> None:
"""网关先落盘(候选、裁判都是权威数据),桌面端后到——不能把网关的数据覆盖掉。"""
self.db.log_model_call(self._record())
self.db.log_model_call(self._record(
chosen="不该生效的值",
candidates=[{"provider": "不该生效的值", "text": "x"}],
total_ms=1,
review_reason="命中审核规则「诊断」",
))
with self.db.connect() as con:
rows = con.execute(
"SELECT * FROM model_calls WHERE task_id='task-1'"
).fetchall()
self.assertEqual(len(rows), 1, "同一个 task_id 只能落一行,不是两行")
row = self._row("task-1")
self.assertEqual(row["chosen"], "openai-primary", "第二次上报不该覆盖已有的候选数据")
self.assertEqual(row["review_reason"], "命中审核规则「诊断」", "审核原因只有桌面端知道,必须生效")
def test_blank_task_ids_never_collide(self) -> None:
"""本地非网关路径目前还给不出 task_id,留空时按老行为各插一行。"""
self.db.log_model_call(self._record(task_id="", customer_text="第一条"))
self.db.log_model_call(self._record(task_id="", customer_text="第二条"))
with self.db.connect() as con:
n = con.execute(
"SELECT COUNT(*) AS n FROM model_calls WHERE task_id=''"
).fetchone()["n"]
self.assertEqual(n, 2)
def test_list_model_calls_finds_a_keyword_in_either_side_of_the_exchange(self) -> None:
self.db.log_model_call(self._record(task_id="t1", customer_text="想咨询糖尿病用药"))
self.db.log_model_call(self._record(task_id="t2", customer_text="今天天气不错", reply_text="是呀"))
found = self.db.list_model_calls(days=7, q="糖尿病")
self.assertEqual(found["total"], 1)
self.assertEqual(found["items"][0]["task_id"], "t1")
def test_list_model_calls_parses_candidates_back_into_a_list(self) -> None:
self.db.log_model_call(self._record(task_id="t1"))
found = self.db.list_model_calls(days=7)
self.assertEqual(found["items"][0]["candidates"][0]["provider"], "openai-primary")
def test_list_model_calls_respects_the_days_window(self) -> None:
self.db.log_model_call(self._record(task_id="t1"))
with self.db.connect() as con:
con.execute(
"UPDATE model_calls SET created_at = '2000-01-01 00:00:00' WHERE task_id='t1'"
)
con.commit()
found = self.db.list_model_calls(days=7)
self.assertEqual(found["total"], 0)
def test_an_old_database_with_duplicate_task_ids_is_deduped_before_the_index_goes_on(self) -> None:
"""老版本拿会话指纹当 task_id,同一个会话十几轮全共享一个值——建唯一索引前必须先清干净。"""
path = self.root / "old_calls.db"
con = sqlite3.connect(path)
con.executescript(
"""
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '',
roles_version INTEGER NOT NULL DEFAULT 0, judge_mode TEXT NOT NULL DEFAULT '',
chosen TEXT NOT NULL DEFAULT '', judge_winner TEXT NOT NULL DEFAULT '',
judge_score REAL NOT NULL DEFAULT 0, judge_risk TEXT NOT NULL DEFAULT '',
candidates_json TEXT NOT NULL DEFAULT '[]', total_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
VALUES ('d1','same-session','p1','2026-01-01 00:00:01');
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
VALUES ('d1','same-session','p2','2026-01-01 00:00:02');
INSERT INTO model_calls (device_id,task_id,chosen,created_at)
VALUES ('d1','same-session','p3','2026-01-01 00:00:03');
"""
)
con.commit()
con.close()
with mock.patch("builtins.print"):
admin_backend.Database(path).initialize("Admin@123456")
db = admin_backend.Database(path)
with db.connect() as con:
rows = con.execute(
"SELECT task_id, chosen FROM model_calls ORDER BY id"
).fetchall()
self.assertEqual([r["task_id"] for r in rows], ["", "", "same-session"], "只留最新一行的 task_id")
self.assertEqual(rows[-1]["chosen"], "p3", "保留的必须是最新那一行")
# 索引确实建起来了:迁移后再报一次同名 task_id 应该走合并而不是新插一行
db.log_model_call({"task_id": "same-session", "review_reason": "新原因"})
with db.connect() as con:
n = con.execute(
"SELECT COUNT(*) AS n FROM model_calls WHERE task_id='same-session'"
).fetchone()["n"]
merged = con.execute(
"SELECT chosen, review_reason FROM model_calls WHERE task_id='same-session'"
).fetchone()
self.assertEqual(n, 1, "唯一索引必须已经生效,不能再插出第二行")
self.assertEqual(merged["chosen"], "p3", "合并不该覆盖已有数据")
self.assertEqual(merged["review_reason"], "新原因")
def test_running_the_model_calls_migration_twice_is_harmless(self) -> None:
with mock.patch("builtins.print"):
admin_backend.Database(self.root / "t.db").initialize("Admin@123456")
self.db.log_model_call(self._record(task_id="tx"))
self.assertEqual(self._row("tx")["chosen"], "openai-primary")
class GuardCallsStayOutOfTheCustomerLogTest(unittest.TestCase):
"""界面识别调用不能混进"客服对话"的调用日志和调用统计。
这是用户真实报上来的 bug:调用记录点开一看,客户消息和模型回复全是「(空)」,
候选里全是 `{"state":"unknown","navigation_right_ratio":0,...}`——那是机器人
自己在看企业微信窗口,不是在回客户。守卫每轮轮询都要问一次模型,条数远多于
真实对话,不分开的话这张表根本没法用来查问题,裁判分布也在给错误的东西打分。
"""
def setUp(self) -> None:
self.root = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.db = admin_backend.Database(self.root / "t.db")
with mock.patch("builtins.print"):
self.db.initialize("Admin@123456")
def _log(self, task_id: str, purpose: str, **extra) -> None:
record = {
"device_id": "d1", "task_id": task_id, "chosen": "p1",
"judge": {"winner": "A", "score": 0.9, "risk": "low"},
"candidates": [{"provider": "p1", "text": "文本"}], "total_ms": 100,
"customer_text": "客户说的", "reply_text": "模型答的",
"purpose": purpose,
}
record.update(extra)
self.db.log_model_call(record)
def test_the_default_log_shows_only_customer_chats(self) -> None:
self._log("c1", "chat")
self._log("g1", "guard")
self._log("g2", "guard")
found = self.db.list_model_calls(days=7)
self.assertEqual(found["total"], 1)
self.assertEqual(found["items"][0]["task_id"], "c1")
def test_guard_calls_can_still_be_listed_on_purpose(self) -> None:
"""内部调用照样在花钱,需要的时候得能翻出来看,不能查无此物。"""
self._log("c1", "chat")
self._log("g1", "guard")
self.assertEqual(self.db.list_model_calls(days=7, purpose="guard")["total"], 1)
self.assertEqual(self.db.list_model_calls(days=7, purpose="")["total"], 2)
def test_stats_ignore_guard_calls(self) -> None:
"""裁判分布要量的是"发给客户的回复够不够好",不是"布局认得准不准"。"""
self._log("c1", "chat", judge={"winner": "A", "score": 0.9, "risk": "low"})
for index in range(5):
self._log(
f"g{index}", "guard",
judge={"winner": "B", "score": 0.1, "risk": "high"},
)
stats = self.db.model_call_stats(7)
self.assertEqual(stats["total"], 1, "5 条界面识别不该被算成客服调用")
self.assertEqual(stats["judged"], 1)
self.assertAlmostEqual(stats["avg_score"], 0.9, places=3)
self.assertNotIn("high", stats["risk"], "守卫的低分不该污染风险占比")
def test_a_call_without_an_explicit_purpose_counts_as_a_customer_chat(self) -> None:
"""桌面端补报的那条路不带 purpose——默认必须是 chat,不能凭空消失。"""
self.db.log_model_call({
"task_id": "c9", "chosen": "p1", "judge": {}, "candidates": [],
"customer_text": "在吗", "reply_text": "在的",
})
self.assertEqual(self.db.list_model_calls(days=7)["total"], 1)
def test_legacy_guard_rows_are_reclassified_by_their_fingerprint(self) -> None:
"""老库里的守卫调用落的是默认 chat,要按候选内容的指纹认出来并改判。
指纹用的是分类器专用的 JSON 字段名(布局守卫的 navigation_right_ratio、
会话行分类器的 reply_capable)——正常客服回复里不可能出现这些词。
"""
path = self.root / "legacy.db"
con = sqlite3.connect(path)
con.executescript(
"""
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '',
roles_version INTEGER NOT NULL DEFAULT 0, judge_mode TEXT NOT NULL DEFAULT '',
chosen TEXT NOT NULL DEFAULT '', judge_winner TEXT NOT NULL DEFAULT '',
judge_score REAL NOT NULL DEFAULT 0, judge_risk TEXT NOT NULL DEFAULT '',
candidates_json TEXT NOT NULL DEFAULT '[]', total_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
INSERT INTO model_calls (task_id,candidates_json,created_at) VALUES
('L1','[{"provider":"a","text":"{\\"state\\":\\"unknown\\",\\"navigation_right_ratio\\":0}"}]','2026-01-01 00:00:01'),
('L2','[{"provider":"a","text":"{\\"kind\\":\\"customer_chat\\",\\"reply_capable\\":true}"}]','2026-01-01 00:00:02'),
('L3','[{"provider":"a","text":"忙完了,歇会儿吧,最近还好吗"}]','2026-01-01 00:00:03');
"""
)
con.commit()
con.close()
with mock.patch("builtins.print"):
admin_backend.Database(path).initialize("Admin@123456")
db = admin_backend.Database(path)
with db.connect() as con:
marks = {
row["task_id"]: row["purpose"]
for row in con.execute("SELECT task_id, purpose FROM model_calls")
}
self.assertEqual(marks["L1"], "guard", "布局守卫的记录要改判")
self.assertEqual(marks["L2"], "guard", "会话行分类器的记录也要改判")
self.assertEqual(marks["L3"], "chat", "真实客服回复绝不能被误判成内部调用")
def test_the_reclassification_never_deletes_anything(self) -> None:
self._log("g1", "chat", candidates=[
{"provider": "a", "text": '{"navigation_right_ratio":0.1}'}
])
before = self.db.list_model_calls(days=7, purpose="")["total"]
with self.db.connect() as con:
admin_backend.Database._backfill_guard_purpose(con)
con.commit()
after = self.db.list_model_calls(days=7, purpose="")["total"]
self.assertEqual(before, after, "只改分类标记,一行都不能少")
self.assertEqual(self.db.list_model_calls(days=7, purpose="guard")["total"], 1)
def setUpModule():
# 别让测试读到开发机上的真实桌面端配置——配过模型网关的机器会让
# `ai_chat.current_provider()` 整体改走网关分支,一大片无关测试跟着变行为。
local_state_redirect.start()
def tearDownModule():
local_state_redirect.stop()
if __name__ == "__main__":
unittest.main()