"""DeepSeek API のローカルモックサーバー。 DeepSeek の公式ドキュメント(Search & Fetch / Thinking Mode / Tool Calls の各ガイド)が 定める挙動を、標準ライブラリだけで再現する。API キーが無くても 01〜05 を動かせる。 起動: python mock_server.py --port 8787 テストからの利用: server, port = make_server(port=0) thread = serve_in_thread(server) ... server.shutdown() server.server_close() """ from __future__ import annotations import argparse import json import re import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any KNOWN_MODELS = {"deepseek-flash", "deepseek-v4-pro"} def _usage_from_text(prompt_text: str, completion_text: str) -> dict[str, int]: # トークン数は概算(文字数 / 2 を四捨五入、最低 1)。モックなので厳密さは不要。 prompt_tokens = max(1, round(len(prompt_text) / 2)) completion_tokens = max(1, round(len(completion_text) / 2)) return { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, "prompt_cache_hit_tokens": 0, "prompt_cache_miss_tokens": prompt_tokens, } def _make_search_result(index: int, query: str, need_content: bool) -> dict[str, Any]: # 公式ガイド(Search & Fetch)の例に合わせ、rerank_score / authority は固定の降順系列、1 件だけ date が null。 rerank_scores = [2.6, 2.1, 1.7, 1.2, 0.8, 0.4] authorities = [4, 3, 3, 2, -1, 1] score = rerank_scores[index] authority = authorities[index] date = None if index == 4 else f"2026-10-{10 + index:02d}T00:00:00+00:00" summary = ( f"これは「{query}」に関するモックの要約です。" "検索結果の本文を要約した体で、モデルに読ませる用途を想定しています。" * 6 )[:620] return { "rerank_score": score, "title": f"モック結果 {index + 1}: {query}", "url": f"https://example.com/mock/{index + 1}", "snippet": f"「{query}」に関する短い要約(モック、{index + 1} 件目)。", "summary": summary, "content": (f"# モック結果 {index + 1}\n\n「{query}」の本文(モック)。" if need_content else ""), "site_name": f"Example Site {index + 1}", "site_icon": f"https://example.com/favicon{index + 1}.ico", "date": date, "authority": authority, } _URL_RE = re.compile(r"^(https?)://([^/@\s]+@)?([^/\s]+)(/.*)?$") def _validate_fetch_url(url: Any) -> str | None: """問題があればエラーメッセージを返す。問題なければ None。""" if not isinstance(url, str) or not url: return "'url' is required" if len(url) > 8192: return "'url' must be at most 8192 characters" m = _URL_RE.match(url) if not m: return "'url' must be an http or https URL with a host" if m.group(2): return "'url' must not contain a username or password" return None class MockHandler(BaseHTTPRequestHandler): server: "MockServer" def log_message(self, format: str, *args: Any) -> None: # noqa: A002 pass # テスト実行中のコンソールを汚さない def _send_json(self, status: int, payload: dict[str, Any]) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _record(self, path: str, body: Any) -> None: self.server.requests.append( { "method": "POST", "path": path, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": body, } ) def do_POST(self) -> None: # noqa: N802 try: self._dispatch() except Exception as exc: # 想定外の例外でソケットを落とさず 500 を返す self._send_json(500, {"error": {"message": str(exc), "type": "internal_error", "param": None, "code": "internal_error"}}) def _dispatch(self) -> None: length = int(self.headers.get("Content-Length", "0") or "0") raw = self.rfile.read(length) if length else b"" auth = self.headers.get("Authorization", "") if not auth.startswith("Bearer ") or not auth[len("Bearer "):].strip(): self._send_json(401, {"error": {"message": "missing or invalid API key", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return try: body = json.loads(raw) if raw else {} except json.JSONDecodeError: self._send_json(400, {"error": {"message": "invalid JSON body", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return self._record(self.path, body) if self.path == "/v1/tools/search": self._handle_search(body) elif self.path == "/v1/tools/fetch": self._handle_fetch(body) elif self.path == "/chat/completions": self._handle_chat(body) else: self._send_json(404, {"error": {"message": "not found", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) def _handle_search(self, body: dict[str, Any]) -> None: query = body.get("query") if not isinstance(query, str) or not (1 <= len(query) <= 128) or not query.strip(): self._send_json(400, _error_body_dict("'query' must be between 1 and 128 characters")) return count = body.get("count", 10) if isinstance(count, bool) or not isinstance(count, int) or not (1 <= count <= 50): self._send_json(400, _error_body_dict("'count' must be between 1 and 50")) return ori_question = body.get("ori_question") if ori_question is not None and (not isinstance(ori_question, str) or not (1 <= len(ori_question) <= 128)): self._send_json(400, _error_body_dict("'ori_question' must be between 1 and 128 characters")) return need_content = body.get("need_content", False) if not isinstance(need_content, bool): self._send_json(400, _error_body_dict("'need_content' must be a boolean")) return if "mock:500" in query: self._send_json(500, {"error": {"message": "internal error (mock)", "type": "internal_error", "param": None, "code": "internal_error"}}) return if "mock:402" in query: self._send_json(402, {"error": {"message": "Insufficient Balance", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return if "mock:429" in query: counts = self.server.search_429_counts seen = counts.get(query, 0) counts[query] = seen + 1 if seen < 2: self._send_json(429, {"error": {"message": "QPS limit exceeded (mock)", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return # 3 回目は通常どおり成功させる。 if "mock:408" in query: counts = self.server.search_408_counts seen = counts.get(query, 0) counts[query] = seen + 1 if seen < 2: self._send_json(408, {"error": {"message": "request body read timed out (mock)", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return # 3 回目は通常どおり成功させる。 if "mock:empty" in query: self._send_json(200, {"results": []}) return n = min(count, 6) results = [_make_search_result(i, query, need_content) for i in range(n)] self._send_json(200, {"results": results}) def _handle_fetch(self, body: dict[str, Any]) -> None: url = body.get("url") error = _validate_fetch_url(url) if error: self._send_json(400, _error_body_dict(error)) return m = _URL_RE.match(url) host = m.group(3) if host == "blocked.example": self._send_json(403, {"error": {"message": "rejected by security policy", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return if host == "missing.example": self._send_json(404, {"error": {"message": "could not retrieve usable page content", "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}}) return self._send_json(200, {"title": "Mock page", "content": f"# Mock page\n\nこれは {url} のモック本文です。"}) def _handle_chat(self, body: dict[str, Any]) -> None: model = body.get("model") if model not in KNOWN_MODELS: self._send_json(400, _error_body_dict(f"unknown model '{model}'")) return thinking = body.get("thinking") or {} is_thinking = thinking.get("type") != "disabled" messages = body.get("messages") or [] tools = body.get("tools") # 演習用: 質問文に mock:chat400 が含まれていれば、クライアント側のエラー処理 # (openai の例外をどう表示するか)を確かめるためにわざと 400 を返す。 if any("mock:chat400" in str(message.get("content", "")) for message in messages): self._send_json(400, _error_body_dict("forced 400 for testing (mock:chat400)")) return if tools and is_thinking: # 公式ガイド(Thinking Mode): tools 付きの要求では、過去の全 assistant ターンの # reasoning_content を返送する必要がある。公式テキストは「返送する必要がある」としか # 書いておらず、空文字を拒否するとは書いていないため、このモックはキーの有無だけを見る # (これはモックによる解釈であり、公式ドキュメントがここまで明記しているわけではない)。 for message in messages: if message.get("role") == "assistant" and "reasoning_content" not in message: self._send_json( 400, _error_body_dict( "reasoning_content of previous assistant messages must be passed back when tools are used" ), ) return last = messages[-1] if messages else {} content, reasoning_content, tool_calls, finish_reason = self._build_chat_reply( messages, last, tools, is_thinking ) message: dict[str, Any] = {"role": "assistant", "content": content} if is_thinking: message["reasoning_content"] = reasoning_content if tool_calls: message["tool_calls"] = tool_calls prompt_text = json.dumps(messages, ensure_ascii=False) usage = _usage_from_text(prompt_text, content + (reasoning_content or "")) self._send_json( 200, { "id": "mock-chatcmpl-1", "object": "chat.completion", "created": int(time.time()), "model": model, "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}], "usage": usage, }, ) def _build_chat_reply( self, messages: list[dict[str, Any]], last: dict[str, Any], tools: Any, is_thinking: bool ) -> tuple[str, str | None, list[dict[str, Any]] | None, str]: if tools: if last.get("role") == "user": query = str(last.get("content", ""))[:128] or "検索" tool_calls = [ { "id": "call_mock_1", "type": "function", "function": { "name": "web_search", "arguments": json.dumps({"query": query, "count": 3}, ensure_ascii=False), }, } ] return "", "ユーザーの質問に答えるため、まず web_search で情報を集めます。", tool_calls, "tool_calls" if last.get("role") == "tool": tool_result_text = str(last.get("content", "")) url_match = re.search(r"https?://\S+", tool_result_text) already_fetched = any( m.get("role") == "assistant" and any(tc.get("function", {}).get("name") == "web_fetch" for tc in (m.get("tool_calls") or [])) for m in messages ) if url_match and not already_fetched: url = url_match.group(0).rstrip('",}') tool_calls = [ { "id": "call_mock_2", "type": "function", "function": { "name": "web_fetch", "arguments": json.dumps({"url": url}, ensure_ascii=False), }, } ] return "", "検索結果の中から有力なページを 1 件取得します。", tool_calls, "tool_calls" # ツール結果をもとに最終回答を組み立てる。 titles = re.findall(r'"title":\s*"([^"]*)"', tool_result_text) count_desc = f"{len(titles)} 件" if titles else "情報" title_desc = f"(例: {titles[0]})" if titles else "" answer = f"収集した{count_desc}の資料{title_desc}をもとに回答します(モック応答)。" return answer, "収集した資料を踏まえて最終回答をまとめます。", None, "stop" # tools はあるが上記に当てはまらない初回メッセージ等 → 検索から開始。 query = str(last.get("content", "質問"))[:128] tool_calls = [ { "id": "call_mock_1", "type": "function", "function": { "name": "web_search", "arguments": json.dumps({"query": query, "count": 3}, ensure_ascii=False), }, } ] return "", "まず検索します。", tool_calls, "tool_calls" # tools なし: 最後の user 発言を引用した単純な回答。 user_text = str(last.get("content", "")) has_citation_instruction = any( m.get("role") == "system" and "出典" in str(m.get("content", "")) for m in messages ) reasoning = f"「{user_text[:40]}」について考えます。" if is_thinking else None if has_citation_instruction: answer = f"「{user_text}」についてお答えします。資料に基づく回答です。[1]" else: answer = f"「{user_text}」についてお答えします(モック応答)。" return answer, reasoning, None, "stop" def _error_body_dict(message: str) -> dict[str, Any]: # 公式ガイド(Search & Fetch)の Errors: OpenAI 互換のエラー形式。param は常に null。 return {"error": {"message": message, "type": "invalid_request_error", "param": None, "code": "invalid_request_error"}} class MockServer(ThreadingHTTPServer): daemon_threads = True def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.requests: list[dict[str, Any]] = [] # mock:429 / mock:408 は「同じクエリ文字列」ごとに最初の 2 回だけ失敗させ、3 回目で成功させる。 # サーバーインスタンスごとに持つので、モックを再起動すれば回数はリセットされる。 self.search_429_counts: dict[str, int] = {} self.search_408_counts: dict[str, int] = {} def make_server(port: int = 8787) -> tuple[MockServer, int]: """モックサーバーを作成する(まだ起動しない)。port=0 で空きポートを自動選択。""" server = MockServer(("127.0.0.1", port), MockHandler) return server, server.server_address[1] def serve_in_thread(server: MockServer) -> threading.Thread: """バックグラウンドスレッドでサーバーを動かす。終了時は server.shutdown() を呼ぶこと。""" thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return thread def main() -> None: parser = argparse.ArgumentParser(description="DeepSeek API のローカルモックサーバー") parser.add_argument("--port", type=int, default=8787) args = parser.parse_args() server, port = make_server(args.port) print(f"モックサーバーを起動しました: http://127.0.0.1:{port}") print("Ctrl+C で終了します。") try: server.serve_forever() except KeyboardInterrupt: pass finally: server.shutdown() server.server_close() if __name__ == "__main__": main()