Files
2026-10-03 14:56:56 +08:00

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()