1
0

feat: 抽取 scripts/core.py 公共模块,重构检测脚本

将 anthropic_detect.py 和 openai_detect.py 中的公共功能抽取到
core.py 模块,包括:
- HTTP 请求(普通/流式)及重试逻辑
- SSL 上下文管理
- 测试用例/结果数据结构 (TestCase, TestResult)
- 错误分类 (ErrorType)
- 响应验证辅助函数 (validate_response_structure 等)
- 测试执行框架 (run_test, run_test_suite)

两个检测脚本重构后更聚焦于各自 API 的测试用例定义。
This commit is contained in:
2026-04-21 11:45:21 +08:00
parent f3a207fa16
commit 7f0f831226
3 changed files with 1446 additions and 549 deletions

View File

@@ -11,115 +11,157 @@
"""
import json
import time
import ssl
import argparse
import urllib.request
import urllib.error
TIMEOUT = 30
from typing import Dict, List, Tuple, Any
from core import (
create_ssl_context,
TestCase,
run_test_suite,
validate_response_structure,
format_validation_errors
)
def create_ssl_context():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def http_request(url, method="GET", headers=None, body=None, ssl_ctx=None):
req = urllib.request.Request(url, method=method)
if headers:
for k, v in headers.items():
req.add_header(k, v)
if body is not None:
if isinstance(body, str):
req.data = body.encode("utf-8")
else:
req.data = json.dumps(body).encode("utf-8")
start = time.time()
try:
resp = urllib.request.urlopen(req, timeout=TIMEOUT, context=ssl_ctx)
elapsed = time.time() - start
return resp.getcode(), resp.read().decode("utf-8"), elapsed
except urllib.error.HTTPError as e:
elapsed = time.time() - start
return e.code, e.read().decode("utf-8"), elapsed
except Exception as e:
elapsed = time.time() - start
return None, str(e), elapsed
def http_stream_request(url, headers=None, body=None, ssl_ctx=None):
req = urllib.request.Request(url, method="POST")
if headers:
for k, v in headers.items():
req.add_header(k, v)
if body is not None:
req.data = json.dumps(body).encode("utf-8")
start = time.time()
try:
resp = urllib.request.urlopen(req, timeout=TIMEOUT, context=ssl_ctx)
status = resp.getcode()
lines = []
for raw_line in resp:
line = raw_line.decode("utf-8").rstrip("\n\r")
if line:
lines.append(line)
elapsed = time.time() - start
return status, "\n".join(lines), elapsed
except urllib.error.HTTPError as e:
elapsed = time.time() - start
return e.code, e.read().decode("utf-8"), elapsed
except Exception as e:
elapsed = time.time() - start
return None, str(e), elapsed
def format_json(text):
try:
parsed = json.loads(text)
return json.dumps(parsed, ensure_ascii=False, indent=2)
except (json.JSONDecodeError, TypeError):
return text
def build_headers(api_key):
def build_headers(api_key: str) -> Dict[str, str]:
"""构建 OpenAI API 请求头"""
h = {"Content-Type": "application/json"}
if api_key:
h["Authorization"] = f"Bearer {api_key}"
return h
def run_test(index, total, desc, url, method, headers, body, stream, ssl_ctx):
print(f"\n[{index}/{total}] {desc}")
print(f">>> {method} {url}")
if body is not None:
if isinstance(body, str):
print(body)
# ==================== OpenAI 响应验证函数 ====================
def validate_openai_models_list_response(response_text: str) -> Tuple[bool, List[str]]:
"""验证 OpenAI Models List 响应
根据API文档响应应包含
- object: "list"
- data: array of Model objects
"""
errors = []
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
return False, [f"响应不是有效的JSON: {e}"]
# 检查必需字段
if "object" not in data:
errors.append("缺少必需字段: object")
elif data["object"] != "list":
errors.append(f"字段 'object' 值错误: 期望 'list', 实际 '{data['object']}'")
if "data" not in data:
errors.append("缺少必需字段: data")
elif not isinstance(data["data"], list):
errors.append(f"字段 'data' 类型错误: 期望 list, 实际 {type(data['data']).__name__}")
else:
# 验证每个 model 对象
for i, model in enumerate(data["data"]):
if not isinstance(model, dict):
errors.append(f"data[{i}] 不是对象")
continue
# 检查 model 对象的必需字段
model_required = ["id", "object", "created", "owned_by"]
for field in model_required:
if field not in model:
errors.append(f"data[{i}] 缺少必需字段: {field}")
# 检查 object 字段值
if "object" in model and model["object"] != "model":
errors.append(f"data[{i}].object 值错误: 期望 'model', 实际 '{model['object']}'")
return len(errors) == 0, errors
def validate_openai_model_retrieve_response(response_text: str) -> Tuple[bool, List[str]]:
"""验证 OpenAI Model Retrieve 响应
根据API文档响应应包含
- id: string
- object: "model"
- created: number
- owned_by: string
"""
required_fields = ["id", "object", "created", "owned_by"]
field_types = {
"id": str,
"object": str,
"created": (int, float),
"owned_by": str
}
enum_values = {
"object": ["model"]
}
return validate_response_structure(response_text, required_fields, field_types, enum_values)
def validate_openai_chat_completion_response(response_text: str) -> Tuple[bool, List[str]]:
"""验证 OpenAI Chat Completion 响应
根据API文档响应应包含
- id: string
- object: "chat.completion"
- created: number
- model: string
- choices: array
- usage: object (可选)
"""
errors = []
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
return False, [f"响应不是有效的JSON: {e}"]
# 检查必需字段
required_fields = ["id", "object", "created", "model", "choices"]
for field in required_fields:
if field not in data:
errors.append(f"缺少必需字段: {field}")
# 检查 object 字段值
if "object" in data and data["object"] != "chat.completion":
errors.append(f"字段 'object' 值错误: 期望 'chat.completion', 实际 '{data['object']}'")
# 检查 choices 数组
if "choices" in data:
if not isinstance(data["choices"], list):
errors.append(f"字段 'choices' 类型错误: 期望 list, 实际 {type(data['choices']).__name__}")
else:
print(format_json(json.dumps(body, ensure_ascii=False)))
for i, choice in enumerate(data["choices"]):
if not isinstance(choice, dict):
errors.append(f"choices[{i}] 不是对象")
continue
if stream:
status, data, elapsed = http_stream_request(url, headers, body, ssl_ctx)
else:
status, data, elapsed = http_request(url, method, headers, body, ssl_ctx)
# 检查 choice 对象的必需字段
choice_required = ["index", "message", "finish_reason"]
for field in choice_required:
if field not in choice:
errors.append(f"choices[{i}] 缺少必需字段: {field}")
if status is not None:
print(f"状态码: {status} | 耗时: {elapsed:.2f}s")
else:
print(f"请求失败 | 耗时: {elapsed:.2f}s")
# 检查 message 对象
if "message" in choice and isinstance(choice["message"], dict):
msg = choice["message"]
if "role" not in msg:
errors.append(f"choices[{i}].message 缺少必需字段: role")
elif msg["role"] != "assistant":
errors.append(f"choices[{i}].message.role 值错误: 期望 'assistant', 实际 '{msg['role']}'")
if stream and status and status < 300:
# 流式响应按 SSE 行逐行输出
for line in data.split("\n"):
print(line)
else:
print(format_json(data))
# 检查 usage 对象(可选)
if "usage" in data and data["usage"] is not None:
if not isinstance(data["usage"], dict):
errors.append(f"字段 'usage' 类型错误: 期望 object, 实际 {type(data['usage']).__name__}")
else:
usage_required = ["prompt_tokens", "completion_tokens", "total_tokens"]
for field in usage_required:
if field not in data["usage"]:
errors.append(f"usage 缺少必需字段: {field}")
return status
return len(errors) == 0, errors
def main():
@@ -154,57 +196,79 @@ def main():
chat_url = f"{base_url}/chat/completions"
# --- 收集用例: (描述, 方法, URL, 请求头, 请求体, 是否流式) ---
cases = []
# --- 收集测试用例 ---
cases: List[TestCase] = []
# ---- Models API ----
cases.append((
"获取模型列表 (GET /models)",
"GET", f"{base_url}/models", headers, None, False
cases.append(TestCase(
desc="获取模型列表 (GET /models)",
method="GET",
url=f"{base_url}/models",
headers=headers,
validator=validate_openai_models_list_response
))
cases.append((
"获取指定模型详情 (GET /models/{model})",
"GET", f"{base_url}/models/{model}", headers, None, False
cases.append(TestCase(
desc="获取指定模型详情 (GET /models/{model})",
method="GET",
url=f"{base_url}/models/{model}",
headers=headers,
validator=validate_openai_model_retrieve_response
))
cases.append((
"获取不存在的模型 (GET /models/nonexistent-model-xxx)",
"GET", f"{base_url}/models/nonexistent-model-xxx", headers, None, False
cases.append(TestCase(
desc="获取不存在的模型 (GET /models/nonexistent-model-xxx)",
method="GET",
url=f"{base_url}/models/nonexistent-model-xxx",
headers=headers
))
# ---- Chat Completions: 正面用例 ----
cases.append((
"基本对话(仅 user",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="基本对话(仅 user",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
}, False
},
validator=validate_openai_chat_completion_response
))
cases.append((
"system + user 对话",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="system + user 对话",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "1+1="}
],
"max_tokens": 5
}, False
},
validator=validate_openai_chat_completion_response
))
cases.append((
"developer + user 对话",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="developer + user 对话",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "developer", "content": "You are a helpful assistant."},
{"role": "user", "content": "1+1="}
],
"max_tokens": 5
}, False
}
))
cases.append((
"多轮对话(含 assistant 历史)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="多轮对话(含 assistant 历史)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "user", "content": "Hi"},
@@ -212,74 +276,98 @@ def main():
{"role": "user", "content": "1+1="}
],
"max_tokens": 5
}, False
}
))
cases.append((
"temperature + top_p",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="temperature + top_p",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"temperature": 0.5,
"top_p": 0.9
}, False
}
))
cases.append((
"max_tokens 限制",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="max_tokens 限制",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "讲一个故事"}],
"max_tokens": 10
}, False
}
))
cases.append((
"stop sequences",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="stop sequences",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "数数: 1,2,3,"}],
"max_tokens": 20,
"stop": ["5"]
}, False
}
))
cases.append((
"n=2 多候选",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="n=2 多候选",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"n": 2
}, False
}
))
cases.append((
"seed 参数",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="seed 参数",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"seed": 42
}, False
}
))
cases.append((
"frequency_penalty + presence_penalty",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="frequency_penalty + presence_penalty",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"frequency_penalty": 0.5,
"presence_penalty": 0.5
}, False
}
))
cases.append((
"max_completion_tokens 参数",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="max_completion_tokens 参数",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "讲一个故事"}],
"max_completion_tokens": 10
}, False
}
))
cases.append((
"JSON mode (response_format: json_object)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="JSON mode (response_format: json_object)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "system", "content": "以 JSON 格式回复: {\"answer\": \"ok\"}"},
@@ -287,51 +375,69 @@ def main():
],
"max_tokens": 10,
"response_format": {"type": "json_object"}
}, False
}
))
# ---- Chat Completions: 负面用例 ----
cases.append((
"缺少 model 参数",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="缺少 model 参数",
method="POST",
url=chat_url,
headers=headers,
body={
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
}, False
}
))
cases.append((
"缺少 messages 参数",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="缺少 messages 参数",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"max_tokens": 5
}, False
}
))
cases.append((
"messages 为空数组",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="messages 为空数组",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [],
"max_tokens": 5
}, False
}
))
cases.append((
"无效 API key",
"POST", chat_url, headers_bad_auth, {
cases.append(TestCase(
desc="无效 API key",
method="POST",
url=chat_url,
headers=headers_bad_auth,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
}, False
}
))
cases.append((
"不存在的模型 (chat)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="不存在的模型 (chat)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": "nonexistent-model-xxx",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5
}, False
}
))
cases.append((
"畸形 JSON body",
"POST", chat_url, headers, "invalid json{", False
cases.append(TestCase(
desc="畸形 JSON body",
method="POST",
url=chat_url,
headers=headers,
body="invalid json{"
))
# ---- --vision ----
@@ -341,9 +447,12 @@ def main():
"Gfp-wisconsin-madison-the-nature-boardwalk.jpg/"
"2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
)
cases.append((
"图片 URL 输入 + detail 参数 (--vision)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="图片 URL 输入 + detail 参数 (--vision)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "system", "content": "简短描述图片"},
@@ -355,39 +464,51 @@ def main():
]}
],
"max_tokens": 10
}, False
}
))
# ---- --stream ----
if args.stream:
cases.append((
"基本流式 (--stream)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="基本流式 (--stream)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"stream": True
}, True
},
stream=True
))
cases.append((
"流式 + include_usage (--stream)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="流式 + include_usage (--stream)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"stream": True,
"stream_options": {"include_usage": True}
}, True
},
stream=True
))
cases.append((
"流式 + stop sequences (--stream)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="流式 + stop sequences (--stream)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "数数: 1,2,3,"}],
"max_tokens": 20,
"stream": True,
"stop": ["5"]
}, True
},
stream=True
))
# ---- --tools ----
@@ -406,29 +527,38 @@ def main():
}
}
}
cases.append((
"工具调用 tool_choice: auto (--tools)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="工具调用 tool_choice: auto (--tools)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "北京天气怎么样?"}],
"max_tokens": 50,
"tools": [tool_weather],
"tool_choice": "auto"
}, False
}
))
cases.append((
"工具调用 tool_choice: required (--tools)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="工具调用 tool_choice: required (--tools)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "北京天气怎么样?"}],
"max_tokens": 50,
"tools": [tool_weather],
"tool_choice": "required"
}, False
}
))
cases.append((
"指定函数调用 tool_choice: {name} (--tools)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="指定函数调用 tool_choice: {name} (--tools)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "北京天气怎么样?"}],
"max_tokens": 50,
@@ -437,11 +567,14 @@ def main():
"type": "function",
"function": {"name": "get_weather"}
}
}, False
}
))
cases.append((
"多轮工具调用(构造 tool 结果)(--tools)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="多轮工具调用(构造 tool 结果)(--tools)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [
{"role": "user", "content": "北京天气怎么样?"},
@@ -457,38 +590,47 @@ def main():
],
"max_tokens": 20,
"tools": [tool_weather]
}, False
}
))
cases.append((
"parallel_tool_calls: false (--tools)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="parallel_tool_calls: false (--tools)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "北京和上海的天气怎么样?"}],
"max_tokens": 50,
"tools": [tool_weather],
"tool_choice": "auto",
"parallel_tool_calls": False
}, False
}
))
# ---- --logprobs ----
if args.logprobs:
cases.append((
"logprobs + top_logprobs (--logprobs)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="logprobs + top_logprobs (--logprobs)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"logprobs": True,
"top_logprobs": 2
}, False
}
))
# ---- --json-schema ----
if args.json_schema:
cases.append((
"Structured Output json_schema (--json_schema)",
"POST", chat_url, headers, {
cases.append(TestCase(
desc="Structured Output json_schema (--json_schema)",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "1+1等于几"}],
"max_tokens": 20,
@@ -508,19 +650,67 @@ def main():
}
}
}
}, False
}
))
# ---- 执行测试 ----
total = len(cases)
count_2xx = 0
count_other = 0
# ---- 高级参数测试 ----
# logit_bias: 修改特定token的似然
cases.append(TestCase(
desc="logit_bias 参数测试",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 5,
"logit_bias": {"1234": -100, "5678": 50} # token_id: bias
}
))
print("=" * 60)
print("OpenAI 兼容性测试")
print(f"目标: {base_url}")
print(f"模型: {model}")
print(f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
# reasoning_effort: 推理努力级别(需要模型支持)
cases.append(TestCase(
desc="reasoning_effort: medium",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "1+1=?"}],
"max_tokens": 10,
"reasoning_effort": "medium"
}
))
# service_tier: 服务层级
cases.append(TestCase(
desc="service_tier: auto",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"service_tier": "auto"
}
))
# verbosity: 冗长程度
cases.append(TestCase(
desc="verbosity: low",
method="POST",
url=chat_url,
headers=headers,
body={
"model": model,
"messages": [{"role": "user", "content": "介绍一下Python"}],
"max_tokens": 50,
"verbosity": "low"
}
))
# ---- 执行测试 ----
flags = []
if args.vision:
flags.append("vision")
@@ -532,20 +722,15 @@ def main():
flags.append("logprobs")
if args.json_schema:
flags.append("json-schema")
print(f"用例: {total}" + (f" | 扩展: {', '.join(flags)}" if flags else ""))
print("=" * 60)
for i, (desc, method, url, hdrs, body, stream) in enumerate(cases, 1):
status = run_test(i, total, desc, url, method, hdrs, body, stream, ssl_ctx)
if status is not None and 200 <= status < 300:
count_2xx += 1
else:
count_other += 1
print()
print("=" * 60)
print(f"测试完成 | 总计: {total} | HTTP 2xx: {count_2xx} | 非 2xx: {count_other}")
print("=" * 60)
run_test_suite(
cases=cases,
ssl_ctx=ssl_ctx,
title="OpenAI 兼容性测试",
base_url=base_url,
model=model,
flags=flags
)
if __name__ == "__main__":