1009 lines
51 KiB
Python
1009 lines
51 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()
|