1009 lines
50 KiB
Python
1009 lines
50 KiB
Python
#!/usr/bin/env python3
|
|
"""mock_server.py - 最小的 OpenAI 兼容服务端, 用于端到端测试 ligent.
|
|
|
|
不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性:
|
|
|
|
- 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象
|
|
- 尚未有 tool 结果 -> 流式返回一个 tool_call (run_command echo ...)
|
|
- 用户消息含 twice 且只执行过一次工具 -> 再返回第二个 tool_call
|
|
- 用户消息含 MANYSTEPS_TEST -> 连要 4 次工具往返才收工
|
|
- 其余 -> 流式/非流式返回最终文本, 内容里回显最近一次工具输出
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
|
|
STATE = {} # 按消息文本隔离的重试计数
|
|
|
|
|
|
def sse(wfile, obj):
|
|
wfile.write(("data: " + json.dumps(obj, ensure_ascii=False) + "\n\n").encode("utf-8"))
|
|
wfile.flush()
|
|
|
|
|
|
def pairing_error(msgs):
|
|
"""服务端的硬要求: 带 tool_calls 的 assistant 后面必须**紧跟**与每个
|
|
tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西.
|
|
|
|
真实服务端就是这么回 400 的, mock 也照做 —— 这样任何会写坏历史的回归
|
|
(比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用)
|
|
都会在 e2e 里当场炸出来, 而不是等到用户真拿它去连服务端才发现.
|
|
返回错误文案, 没问题返回 None. """
|
|
i, n = 0, len(msgs)
|
|
while i < n:
|
|
m = msgs[i]
|
|
if m.get("role") == "assistant" and m.get("tool_calls"):
|
|
want = [tc.get("id") for tc in m["tool_calls"]]
|
|
got, j = [], i + 1
|
|
while j < n and msgs[j].get("role") == "tool":
|
|
got.append(msgs[j].get("tool_call_id"))
|
|
j += 1
|
|
for w in want:
|
|
if w not in got:
|
|
return ("An assistant message with 'tool_calls' must be "
|
|
"followed by tool messages responding to each "
|
|
"'tool_call_id'. (insufficient tool messages "
|
|
"following tool_calls message)")
|
|
i += 1
|
|
return None
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.0"
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
# -- 工具调用 (流式增量) --
|
|
def emit_tool_call(self, index, call_id, name, arguments):
|
|
# 故意把 arguments 切成多片, 检验客户端的增量拼接
|
|
half = len(arguments) // 2
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {
|
|
"role": "assistant", "content": ""}, "finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [
|
|
{"index": index, "id": call_id, "type": "function",
|
|
"function": {"name": name, "arguments": ""}}]}, "finish_reason": None}]})
|
|
for part in (arguments[:half], arguments[half:]):
|
|
if not part:
|
|
continue
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [
|
|
{"index": index, "function": {"arguments": part}}]},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "tool_calls"}]})
|
|
|
|
def emit_text(self, text):
|
|
for piece in (text[: len(text) // 2], text[len(text) // 2:]):
|
|
if not piece:
|
|
continue
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {"content": piece},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1,
|
|
"total_tokens": 2}})
|
|
|
|
def reject(self, status, message):
|
|
"""回一个错误响应 (探测/校验用)"""
|
|
payload = json.dumps({"error": {"message": message}}).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def send_text(self, text, stream, model):
|
|
"""按流式/非流式把一段最终文本发回去. """
|
|
if not stream:
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": text},
|
|
"finish_reason": "stop"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_text(text)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
|
|
def do_GET(self):
|
|
# doctor 的连通性/重定向检查走 GET.
|
|
# /redirect 开头故意返回 307, 用来验证客户端能把重定向指出来.
|
|
# doctor 的认证探测: 端点要求认证, 且只认 "Bearer good-key".
|
|
# 带对 key -> 200 (认证过了)
|
|
# 带错 key -> 401 (密钥被拒)
|
|
# 不带 key -> 401 (端点要求认证, 而你没配)
|
|
# 客户端要靠"有没有配 key"把后两种 401 区分开.
|
|
if self.path.startswith("/authed"):
|
|
auth = self.headers.get("Authorization") or ""
|
|
ok = (auth == "Bearer good-key")
|
|
self.send_response(200 if ok else 401)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", "2")
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
return
|
|
|
|
# /model 不带参数时拉模型列表.
|
|
# 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目.
|
|
# MODEL_LIST=bare 时改回裸数组, MODEL_LIST=empty 时不返回任何模型,
|
|
# 两种兼容/异常形状都由 e2e 覆盖.
|
|
if "/models" in self.path:
|
|
# 模式从**文件**读, 不从环境变量读: mock 是脚本开头起好的,
|
|
# 给客户端进程设的 env 它根本看不见 (第一版就是这么假通过的).
|
|
mode = ""
|
|
mode_file = os.environ.get("MODELS_MODE_FILE", "")
|
|
if mode_file:
|
|
try:
|
|
with open(mode_file, encoding="utf-8") as f:
|
|
mode = f.read().strip()
|
|
except OSError:
|
|
mode = ""
|
|
if mode == "empty":
|
|
payload = json.dumps({"object": "list", "data": []}).encode()
|
|
elif mode == "bare":
|
|
payload = json.dumps(["alpha-model", "beta-model"]).encode()
|
|
elif mode == "garbage":
|
|
payload = b"<html>not json at all</html>"
|
|
else:
|
|
# 默认列表里**故意包含当前模型** (deepseek-flash):
|
|
# 这样才验得了"光标初始落在当前项上".
|
|
# 另外塞一个重复项和一个缺 id 的坏条目, 验去重与跳过.
|
|
payload = json.dumps({"object": "list", "data": [
|
|
{"id": "deepseek-flash", "object": "model", "owned_by": "mock"},
|
|
{"id": "alpha-model", "object": "model", "owned_by": "mock"},
|
|
{"id": "beta-model", "object": "model", "owned_by": "mock"},
|
|
{"id": "alpha-model", "object": "model", "owned_by": "mock"},
|
|
{"object": "model", "owned_by": "mock"},
|
|
]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
if self.path.startswith("/redirect"):
|
|
self.send_response(307)
|
|
self.send_header("Location", "https://example.invalid/v1/chat/completions")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
body = b"{}"
|
|
self.send_response(404) # 端点根路径本来就没有东西
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
raw = self.rfile.read(length)
|
|
|
|
# ---- UTF-8 严格校验: 真服务端就是这么拒的 ----
|
|
# DeepSeek 对请求体里非法的 UTF-8 回的是
|
|
# 400 Failed to parse the request body as JSON:
|
|
# messages[43].content: invalid unicode code point
|
|
# 而坏数据一旦进了对话历史, 之后**每一次**请求都会失败, 除非丢掉整轮
|
|
# 对话. 所以 mock 必须一样严格, 否则"序列化器吐出了非法 UTF-8"这类 bug
|
|
# 在 e2e 里永远看不见.
|
|
# (json.loads(bytes) 其实也会因此抛异常、被下面那个 except 兜成 400,
|
|
# 但那条笼统的 400 什么都没说明, 而且换个实现就可能漏掉, 所以显式判. )
|
|
try:
|
|
raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
self.reject(400, "Failed to parse the request body as JSON: "
|
|
"invalid unicode code point at byte %d" % exc.start)
|
|
return
|
|
|
|
# ---- 假的 Files API ----
|
|
# 只做够用的部分: 认 multipart 里有没有图片内容, 发一个递增的 file_id.
|
|
try:
|
|
body = json.loads(raw or b"{}")
|
|
except Exception:
|
|
self.send_error(400)
|
|
return
|
|
|
|
messages = body.get("messages", [])
|
|
model = body.get("model", "?")
|
|
stream = bool(body.get("stream"))
|
|
|
|
# 把 user 消息拍平成纯文本再匹配. 带附件的消息 content 是**块数组**
|
|
# (/include、read_image 的附件都是), 不拍平的话 first_user 会是个
|
|
# list, `"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上.
|
|
def user_text(m):
|
|
c = m.get("content")
|
|
if isinstance(c, str):
|
|
return c
|
|
if isinstance(c, list):
|
|
return " ".join(p.get("text", "") for p in c
|
|
if isinstance(p, dict) and p.get("type") == "text")
|
|
return ""
|
|
|
|
users = [user_text(m) for m in messages if m.get("role") == "user"]
|
|
first_user = users[0] if users else ""
|
|
last_user = users[-1] if users else ""
|
|
tool_msgs = [m for m in messages if m.get("role") == "tool"]
|
|
tools_sent = body.get("tools")
|
|
|
|
# ---- 结构校验: 和真服务端一样严 ----
|
|
perr = pairing_error(messages)
|
|
if perr:
|
|
self.reject(400, perr)
|
|
return
|
|
|
|
# ---- 能力探测序列 ----
|
|
# ligent 会在第一次真实请求之前发几个试探请求 (基线 / 图片 / tools),
|
|
# 它们会被真实处理, 所以这里必须排在所有标记分支之前.
|
|
# 辨认靠形状: 刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图.
|
|
def looks_like_probe(msgs):
|
|
if len(msgs) != 1 or msgs[0].get("role") != "user":
|
|
return False
|
|
c = msgs[0].get("content")
|
|
if c == "1":
|
|
return True
|
|
if isinstance(c, list):
|
|
for p in c:
|
|
if not isinstance(p, dict) or p.get("type") != "image_url":
|
|
continue
|
|
u = (p.get("image_url") or {}).get("url", "")
|
|
if "iVBORw0KGgo" in u:
|
|
return True
|
|
return False
|
|
|
|
if looks_like_probe(messages):
|
|
has_image = isinstance(messages[0].get("content"), list)
|
|
# 用模型名模拟"不支持某能力"的服务端: 400 是明确的拒绝信号
|
|
if "no-vision" in model and has_image:
|
|
return self.reject(400, "unsupported content type: image_url")
|
|
if "no-tools" in model and tools_sent:
|
|
return self.reject(400, "unsupported parameter: tools")
|
|
if "broken-probe" in model:
|
|
return self.reject(401, "invalid api key")
|
|
self.send_text("1", stream, model)
|
|
return
|
|
|
|
# 提权: 让模型发一个带 elevated 的 run_command
|
|
if "ELEVATED_TEST" in first_user:
|
|
if not tool_msgs:
|
|
args = json.dumps({"command": "echo 提权后的输出", "elevated": True})
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_elev", "run_command", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
got = ""
|
|
for m in messages:
|
|
if m.get("role") == "tool":
|
|
got = m.get("content") or ""
|
|
self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model)
|
|
return
|
|
|
|
# /include: 把收到的 user 内容块报回来.
|
|
# 用 first_user 判断——附带之后最新那条 user 是带块的消息, last_user 会漂.
|
|
if "INCLUDE_TEST" in first_user:
|
|
texts, imgs, names = 0, 0, []
|
|
for m in messages:
|
|
if m.get("role") != "user":
|
|
continue
|
|
c = m.get("content")
|
|
# 没有附件时 content 是纯字符串, 那也算"一个文本块"
|
|
if isinstance(c, str):
|
|
texts += 1
|
|
continue
|
|
if not isinstance(c, list):
|
|
continue
|
|
for p in c:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
if p.get("type") == "text":
|
|
texts += 1
|
|
t = p.get("text", "")
|
|
for line in t.split("\n"):
|
|
if line.startswith("===== "):
|
|
names.append(line[6:].split(" (")[0])
|
|
elif p.get("type") == "image_url":
|
|
imgs += 1
|
|
self.send_text("INC[text=%d img=%d files=%s]"
|
|
% (texts, imgs, ",".join(names)), stream, model)
|
|
return
|
|
|
|
# 回显收到的工具名, 用来验证"不该暴露的工具确实没发出去"
|
|
if "TOOLS_ECHO_TEST" in first_user:
|
|
names = [((t or {}).get("function") or {}).get("name")
|
|
for t in (tools_sent or [])]
|
|
self.send_text("TOOLS[%s]" % ",".join(n for n in names if n),
|
|
stream, model)
|
|
return
|
|
|
|
# 权限/参数校验: 客户端必须把 tools 传上来
|
|
assert tools_sent, "请求里没有 tools 字段"
|
|
|
|
if "ERROR_TEST" in first_user:
|
|
payload = json.dumps({"error": {"message": "mock 模拟的服务端错误",
|
|
"type": "invalid_request_error"}}).encode()
|
|
self.send_response(400)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 消息条数超过阈值就报"上下文超限", 用于验证客户端的自动裁剪.
|
|
# 客户端丢掉最老的一轮后条数会降下来, 于是下一轮就能成功.
|
|
if "TOOLONG_TEST" in first_user:
|
|
if len(messages) > 3:
|
|
payload = json.dumps({"error": {
|
|
"message": "This model's maximum context length is 8192 tokens. "
|
|
"However, your messages resulted in 9000 tokens. "
|
|
"Please reduce the length of the messages.",
|
|
"code": "context_length_exceeded"}}).encode()
|
|
self.send_response(400)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
text = "当前历史 %d 条消息, 正常回复" % len(messages)
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": text},
|
|
"finish_reason": "stop"}]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 把收到的用户消息原样回显, 用于验证"一次请求里到底装了什么"
|
|
if "ECHO_TEST" in first_user:
|
|
text = "ECHO[" + first_user.replace("\n", "\\n") + "]"
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": text},
|
|
"finish_reason": "stop"}]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 复刻 OpenRouter 对"key 格式不对"的回应: 头其实带了,
|
|
# 但文案说的是 "Missing Authentication header", 容易把人引偏
|
|
if "AUTHFAIL_TEST" in first_user:
|
|
payload = json.dumps({"error": {"message": "Missing Authentication header",
|
|
"code": 401}}).encode()
|
|
self.send_response(401)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 重定向: 用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization,
|
|
# 服务端只会返回 401 "Missing Authentication header", 误导用户去查 key)
|
|
if "REDIRECT_TEST" in first_user:
|
|
self.send_response(307)
|
|
self.send_header("Location", "https://example.invalid/v1/chat/completions")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
|
|
# 前几次返回 503, 用于验证重试与 Retry-After
|
|
if "RETRY_TEST" in first_user:
|
|
key = "retry:" + first_user
|
|
STATE[key] = STATE.get(key, 0) + 1
|
|
STATE["retry"] = STATE[key]
|
|
if STATE["retry"] < 3:
|
|
payload = json.dumps({"error": {
|
|
"message": "mock 临时不可用 (第 %d 次)" % STATE["retry"]}}).encode()
|
|
self.send_response(503)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Retry-After", "1")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
text = "重试后成功 (共尝试 %d 次)" % STATE["retry"]
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": text},
|
|
"finish_reason": "stop"}]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 永远 503, 用于验证重试次数耗尽后正确报错
|
|
if "ALWAYS503_TEST" in first_user:
|
|
payload = json.dumps({"error": {"message": "mock 持续不可用"}}).encode()
|
|
self.send_response(503)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Retry-After", "1")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
# 超过客户端响应体上限的超大响应: 客户端应当主动中止而不是被撑爆
|
|
if "HUGE_TEST" in first_user:
|
|
total = 40 * 1024 * 1024
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(total))
|
|
self.end_headers()
|
|
chunk = b'{"pad":"' + b'x' * (1024 * 1024 - 12) + b'"}'
|
|
try:
|
|
for _ in range(40):
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass # 客户端主动断开是预期行为
|
|
return
|
|
|
|
# 畸形 SSE: 各种不合法的片段, 客户端必须不崩溃且给出合理结果
|
|
if "GARBAGE_TEST" in first_user:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.end_headers()
|
|
lines = [
|
|
"data: {\n\n",
|
|
"data: not json at all\n\n",
|
|
"data: [DONE]\n\n",
|
|
'data: {"choices":[]}\n\n',
|
|
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
|
|
"\xe6\xb3\xa8\xe9\x87\x8a\xe8\xa1\x8c\n\n", # 非 ASCII 裸行
|
|
"data:\n\n",
|
|
"\x01\x02\xff\xfe\n\n",
|
|
]
|
|
for line in lines:
|
|
self.wfile.write(line.encode("utf-8", "surrogateescape"))
|
|
self.wfile.flush()
|
|
return
|
|
|
|
# 传输中途断掉: 声明了 Content-Length 却提前关闭连接
|
|
if "TRUNCATE_TEST" in first_user:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Content-Length", "100000")
|
|
self.end_headers()
|
|
self.wfile.write(b"data: {\"choices\":[{\"delta\":{\"content\":\"\xe5\x8d\x8a\xe9\x80\x94\xe5\xb4\xa9\xe6\xba\x83\"}}]}\n\n")
|
|
self.wfile.flush()
|
|
self.close_connection = True
|
|
return
|
|
|
|
# 固定的 Markdown 回复, 用于验证流式渲染
|
|
if "MARKDOWN_TEST" in first_user:
|
|
md = (
|
|
"# 一级标题\n"
|
|
"## 二级标题\n"
|
|
"\n"
|
|
"这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排. \n"
|
|
"参考 [官方文档](https://example.com/doc) 与 arr[i]. \n"
|
|
"\n"
|
|
"- 列表甲\n- 列表乙\n"
|
|
"\n"
|
|
"| 名称 | 数量 | 说明 |\n"
|
|
"|:-----|-----:|:----:|\n"
|
|
"| 苹果 | 3 | 常见 |\n"
|
|
"| 香蕉 | 12 | 较长 |\n"
|
|
"\n"
|
|
"```c\n"
|
|
"int x = 1; // **不应加粗** 也不高亮 [方括号]\n"
|
|
"```\n"
|
|
)
|
|
if not stream:
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": md},
|
|
"finish_reason": "stop"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
# 故意按 1 字符一片下发, 最大化"标记被切断"的概率
|
|
for ch in md:
|
|
sse(self.wfile, {"choices": [{"index": 0,
|
|
"delta": {"content": ch},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "stop"}]})
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
|
|
# 思考模式: 验证 reasoning_content 既能被累积, 也能随 assistant 消息
|
|
# 回传. DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上,
|
|
# 漏了下一轮就 400, 所以这里第二轮专门检查它有没有回来.
|
|
if "THINK_TEST" in first_user:
|
|
if not tool_msgs:
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None,
|
|
"reasoning_content": "先想一下要不要调用工具. ",
|
|
"tool_calls": [{"id": "call_1", "type": "function",
|
|
"function": {
|
|
"name": "run_command",
|
|
"arguments": '{"command": "echo think-ok"}'}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
# 思考过程分片下发: 客户端必须累积, 而不是只留最后一片
|
|
for part in ("先想一下", "要不要调用工具. "):
|
|
sse(self.wfile, {"choices": [{"index": 0,
|
|
"delta": {"reasoning_content": part},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [
|
|
{"index": 0, "id": "call_1", "type": "function",
|
|
"function": {"name": "run_command",
|
|
"arguments": '{"command": "echo think-ok"}'}}]},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "tool_calls"}]})
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
|
|
assistants = [m for m in messages if m.get("role") == "assistant"]
|
|
got = "".join(m.get("reasoning_content", "") for m in assistants)
|
|
th = body.get("thinking")
|
|
th = th.get("type") if isinstance(th, dict) else "none"
|
|
self.send_text("THINK[echo=%s thinking=%s effort=%s]" % (
|
|
"yes" if "要不要调用工具" in got else "NO", th,
|
|
body.get("reasoning_effort") or "none"), stream, model)
|
|
return
|
|
|
|
# 慢速响应: 先静默一段 (网络/排队), 再吐思维链, 再静默一段,
|
|
# 最后给正文. 用来验证等待动画的两段标签: "等待响应" → "思考中".
|
|
# 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的,
|
|
# 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡.
|
|
if "SLOWRESP_TEST" in first_user:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
time.sleep(0.8) # 这期间一个字节都没发
|
|
sse(self.wfile, {"choices": [{"index": 0,
|
|
"delta": {"reasoning_content": "先想一下. "},
|
|
"finish_reason": None}]})
|
|
time.sleep(0.8) # 这期间模型在推理
|
|
self.send_text("答案", stream=True, model=model)
|
|
return
|
|
|
|
# meta 模式: 第一轮让 meta 模型调 delete_rounds 删掉第 2 轮,
|
|
# 第二轮收工. 用来端到端验证 /meta 的完整链路.
|
|
if "META_TEST" in first_user:
|
|
if not tool_msgs:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
import json as _j
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {"tool_calls": [
|
|
{"index": 0, "id": "meta_1", "type": "function",
|
|
"function": {"name": "delete_rounds",
|
|
"arguments": _j.dumps({"from": 2, "to": 2})}}]},
|
|
"finish_reason": None}]})
|
|
sse(self.wfile, {"choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "tool_calls"}]})
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("已删除第 2 轮. ", stream, model)
|
|
return
|
|
|
|
# 按"最后一条用户消息"触发: 上面那些模式都看 first_user,
|
|
# 同一个进程里的第二轮就再也触发不了了, 没法验证"作用域仅限本轮".
|
|
if "WRITE_LAST_TEST" in last_user:
|
|
if messages[-1].get("role") == "user": # 新一轮的开头
|
|
args = json.dumps({"path": "build/tmp/confirm_probe_turn2.txt",
|
|
"content": "第二轮的"}, ensure_ascii=False)
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_t2", "type": "function",
|
|
"function": {"name": "write_file", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_t2", "write_file", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("第二轮写完了", stream, model)
|
|
return
|
|
|
|
# 发出一个 write_file 调用: 用来验证"在确认提示上按 Ctrl-C"到底
|
|
# 会不会把文件写下去
|
|
if "WRITE_TEST" in first_user:
|
|
if not tool_msgs:
|
|
args = json.dumps({"path": "build/tmp/confirm_probe.txt",
|
|
"content": "应该被拒绝的内容"}, ensure_ascii=False)
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_w", "type": "function",
|
|
"function": {"name": "write_file", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_w", "write_file", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("写完了", stream, model)
|
|
return
|
|
|
|
# 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常:
|
|
# 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制
|
|
# 文件里混的一小段文本. 这段字节会进对话历史, 然后被序列化进请求体 ——
|
|
# 如果序列化器原样吐出, 服务端就会 400, 而且之后每次请求都 400.
|
|
# 这个分支的作用就是让那条路径在 e2e 里真的被走一遍.
|
|
if "UTF8_TEST" in first_user:
|
|
if not tool_msgs:
|
|
args = json.dumps({"path": "build/tmp/bad_utf8.txt"})
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_u8", "type": "function",
|
|
"function": {"name": "read_file", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_u8", "read_file", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
# 第二轮: 能走到这里就说明请求体过了上面的 UTF-8 校验.
|
|
# 把工具消息里有没有替换字符也顺带报出来, 便于断言.
|
|
got = ""
|
|
for m in tool_msgs:
|
|
c = m.get("content")
|
|
if isinstance(c, str):
|
|
got += c
|
|
self.send_text("读完了, 替换字符=%d" % got.count("\ufffd"), stream, model)
|
|
return
|
|
|
|
# 一轮里连写两个文件: 用来验证 Always/neVer 的作用域就是"这一轮"
|
|
# (名字刻意不含 "WRITE_TEST", 以免被上面那个分支先截胡)
|
|
if "WRITE2_TEST" in first_user:
|
|
n = len(tool_msgs)
|
|
if n < 2:
|
|
args = json.dumps({"path": "build/tmp/confirm_probe%d.txt" % (n + 1),
|
|
"content": "第 %d 个" % (n + 1)})
|
|
call_id = "call_w%d" % n
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": call_id, "type": "function",
|
|
"function": {"name": "write_file", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, call_id, "write_file", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("两个都写完了", stream, model)
|
|
return
|
|
|
|
# 让模型去读一个"扩展名是 .png、内容不是图片"的文件,
|
|
# 验证 read_image 按内容判断、并明确拒绝
|
|
# 两种"读不了": 内容不是图片、以及大得内联不进去.
|
|
# 注意: mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的
|
|
# HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记, 所以这里叫 OVERSIZE.
|
|
# 两者都该在**本地**被拒 —— 工具返回错误, 之后就没有图片块可发.
|
|
if "IMAGE_BAD_TEST" in last_user or "IMAGE_OVERSIZE_TEST" in last_user:
|
|
if not tool_msgs:
|
|
path = ("build/tmp/huge.png" if "IMAGE_OVERSIZE_TEST" in last_user
|
|
else "build/tmp/notanimage.png")
|
|
args = json.dumps({"path": path})
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_bad", "read_image", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("读不了就算了", stream, model)
|
|
return
|
|
|
|
# 一条 assistant 里发**两个**工具调用, 第一个是 read_image.
|
|
# 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状:
|
|
# 只要附件没被攒到最后一起推, 上面的结构校验就会回 400.
|
|
# 注意用 first_user: 图片附件本身也是一条 user 消息, 而且排在最后,
|
|
# 所以第二轮请求的 last_user 是那个附件 (一个 list), 不是这条标记.
|
|
if "IMAGE_PAIR_TEST" in first_user:
|
|
if not tool_msgs:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_img", "read_image",
|
|
json.dumps({"path": "build/tmp/dot.png"}))
|
|
self.emit_tool_call(1, "call_cmd", "run_command",
|
|
json.dumps({"command": "echo pair-ok"}))
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
found = []
|
|
for m in messages:
|
|
c = m.get("content")
|
|
if isinstance(c, list):
|
|
for part in c:
|
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
|
found.append("img")
|
|
self.send_text("PAIR[img=%d tool=%d]"
|
|
% (len(found), len(tool_msgs)), stream, model)
|
|
return
|
|
|
|
# 图片: 先让模型调一次 read_image, 下一轮把收到的图片内联块报回来.
|
|
# Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64.
|
|
if "IMAGE_TEST" in first_user:
|
|
if not tool_msgs:
|
|
args = json.dumps({"path": "build/tmp/dot.png"})
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_img", "type": "function",
|
|
"function": {"name": "read_image", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_img", "read_image", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
|
|
# 第二轮: 请求里应该已经带上内联图片了.
|
|
# 报文形状: {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
|
|
found, notes = [], []
|
|
for m in messages:
|
|
c = m.get("content")
|
|
if isinstance(c, list):
|
|
for part in c:
|
|
if not isinstance(part, dict):
|
|
continue
|
|
if part.get("type") == "image_url":
|
|
url = (part.get("image_url") or {}).get("url", "")
|
|
if url.startswith("data:"):
|
|
head = url.split(";", 1)[0] # data:image/png
|
|
b64 = url.split(",", 1)[1] if "," in url else ""
|
|
found.append("%s:%d" % (head, len(b64)))
|
|
# 图片被拒之后, 附件消息里应该换成一句给模型看的说明.
|
|
# 把它回显出来, 端到端才能证明**模型真的收到了**这句话,
|
|
# 而不只是我们自己的历史被改了.
|
|
elif part.get("type") == "text" and m.get("role") == "user":
|
|
notes.append(part.get("text", ""))
|
|
|
|
# IMAGE_REJECT_TEST: 第一次故意说服务端不收这张图,
|
|
# 客户端应该把图片从历史里摘掉再试 (而不是每轮都失败)
|
|
if "IMAGE_REJECT_TEST" in last_user and not STATE.get("told_once"):
|
|
STATE["told_once"] = 1
|
|
payload = json.dumps({"error": {
|
|
"message": "image rejected: unsupported image variant"}}).encode()
|
|
self.send_response(400)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
reply = "IMAGE[%s]" % (",".join(found) if found else "none")
|
|
if notes:
|
|
reply += " NOTE[" + "|".join(notes) + "]"
|
|
self.send_text(reply, stream, model)
|
|
return
|
|
|
|
# edit_file 的**调用展示**: 成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照,
|
|
# 而不是一整段参数 JSON (old_string/new_string 原样打出来会刷屏).
|
|
# EDITTEST_OK 成功剧本, EDITTEST_FAIL 行号越界剧本.
|
|
if "EDITTEST_OK" in first_user or "EDITTEST_FAIL" in first_user:
|
|
if tool_msgs:
|
|
self.send_text("DONE[%s]" % tool_msgs[-1].get("content", "")[:60],
|
|
stream, model)
|
|
return
|
|
line = 999 if "EDITTEST_FAIL" in first_user else 2
|
|
# 12 行的 old_string: 既覆盖"多行改动"的显示, 也覆盖"超过 8 行要折叠"
|
|
old = "\n".join("OLD%d" % i for i in range(1, 13)) if line == 2 else "OLD"
|
|
args = json.dumps({"path": "build/tmp/edit_probe.txt", "line": line,
|
|
"old_string": old, "new_string": "NEW"})
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_edit", "type": "function",
|
|
"function": {"name": "edit_file", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_edit", "edit_file", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
|
|
# 多步 (4 次工具往返才收工): 用来钉住"往返次数默认不限".
|
|
# 默认配置下必须跑得完; 显式设了正数上限时要停在那个上限上并如实报出.
|
|
if "MANYSTEPS_TEST" in first_user:
|
|
done = len(tool_msgs)
|
|
if done < 4:
|
|
args = json.dumps({"command": "echo step-%d" % (done + 1)})
|
|
if not stream:
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_ms%d" % (done + 1), "type": "function",
|
|
"function": {"name": "run_command", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_tool_call(0, "call_ms%d" % (done + 1), "run_command", args)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
self.send_text("STEPS_DONE[%d]" % done, stream, model)
|
|
return
|
|
|
|
want_tools = "twice" in first_user
|
|
need = 2 if want_tools else 1
|
|
|
|
if len(tool_msgs) < need:
|
|
if not stream:
|
|
args = json.dumps({"command": "echo hello-from-tool"}, ensure_ascii=False)
|
|
msg = {"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_1", "type": "function",
|
|
"function": {"name": "run_command", "arguments": args}}]}
|
|
payload = json.dumps({"choices": [{"index": 0, "message": msg,
|
|
"finish_reason": "tool_calls"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
if len(tool_msgs) == 0:
|
|
self.emit_tool_call(0, "call_1", "run_command",
|
|
'{"command": "echo hello-from-tool"}')
|
|
else:
|
|
self.emit_tool_call(0, "call_2", "run_command",
|
|
'{"command": "echo second-call"}')
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
return
|
|
|
|
last = tool_msgs[-1].get("content", "")
|
|
text = "FINAL: " + last.strip().replace("\n", " / ")
|
|
|
|
if not stream:
|
|
payload = json.dumps({"choices": [{"index": 0,
|
|
"message": {"role": "assistant",
|
|
"content": text},
|
|
"finish_reason": "stop"}],
|
|
"model": model}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
return
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.emit_text(text)
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
|
|
|
|
def main():
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8731
|
|
server = HTTPServer(("127.0.0.1", port), Handler)
|
|
print("mock server on %d" % port, flush=True)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|