| name | webai2api |
| description | Reverse-engineer any web AI chat interface into an OpenAI-compatible API. Upload a .har file (browser DevTools export), and automatically detect the chat endpoint, extract auth headers/cookies, analyze SSE streaming, identify PoW challenges, and generate a proxy server. Use when you need to use a web-only AI model through any OpenAI SDK (Claude Code, Cursor, Continue, etc.). |
webai2api — HAR-driven Web AI to OpenAI API Proxy
Overview
User uploads a .har file. You parse it, extract every detail of the chat API, then generate an OpenAI-compatible proxy. Supports both HTTP (SSE/JSON) and WebSocket transports — auto-detected from the HAR file.
User Instructions
- Open target AI chat page, F12 → Network, check "Preserve log"
- Send a chat message, wait for full reply
- Right-click any request → "Save all as HAR with content"
- Upload the
.har file
Optionally: specify model name (default gpt-4o).
Step 0 — Parse HAR
from har_parser import parse_har
analysis = parse_har("uploaded.har")
0.1 How parse_har identifies the chat entry
It scores every HAR entry by these rules (higher = more likely):
| Criterion | Points |
|---|
| Method is POST | +0 (required, else -100) |
URL path contains /api/chat, /v1/chat, /conversation, /completion etc. | +50 |
Request body has "messages" array | +40 |
"messages" has ≥1 item with non-empty "content" | +20 |
Body has "prompt" field | +30 |
Body has "stream" field | +10 |
Body has "model" field | +10 |
Body has "temperature", "max_tokens", "top_p" | +5 each |
Response Content-Type is text/event-stream | +30 |
Response Content-Type is application/json | +15 |
| Response body is JSON with a long string field (>50 chars) | +5 |
URL path contains .js, .css, .png, analytics, favicon | -50 each |
The entry with the highest score >= 0 is the chat API.
0.3 WebSocket detection (fallback from HTTP)
If no HTTP POST entry scored >= 0, or if a response.status == 101 (Switching Protocols) entry is found, parse_har tries WebSocket detection:
- Find entries with
status == 101 that have a _webSocketMessages array
- Require at least 1 "send" + 1 "receive" message in the array
- Score the messages: JSON body contains
message/content/prompt keywords, long string values, stream flag
- If score >= 3, mark
analysis.has_websocket = True and populate analysis.ws.*
WS scoring criteria:
| Criterion | Points |
|---|
Entry has _webSocketMessages with ≥1 send + ≥1 receive | required |
| Send frame JSON has chat-like keys (message, content, prompt, text, query) | +2 each key |
Send frame JSON has stream field | +1 |
| Send frame JSON has a string value longer than 10 characters | +2 |
| Receive frame JSON has a chat-like key with a long string value | +2 per frame |
| Minimum score to qualify | 3 |
The WS entry also provides:
- Send template: JSON structure of the first send message (used as payload blueprint)
- Init messages: any send messages that lack chat content keys (likely auth/init frames to replay on connect)
- Streaming detection: if ≥3 receive frames for a single send →
receive_is_streaming = True
0.2 What analysis contains
analysis.base_url
analysis.chat_endpoint
analysis.headers
analysis.cookies
analysis.auth_header
analysis.auth_type
analysis.request_body_template
analysis.content_field_path
analysis.is_streaming
analysis.sse_event_type
analysis.sse_data_field
analysis.sse_format
analysis.has_pow
analysis.pow_endpoint
analysis.all_endpoints
analysis.chat_entry_index
analysis.supported_params
analysis.has_websocket
analysis.ws.ws_url
analysis.ws.send_template
analysis.ws.input_field
analysis.ws.receive_field
analysis.ws.receive_is_streaming
analysis.ws.type_field
analysis.ws.extra_send_fields
analysis.ws.init_messages
Step 1 — Determine the Payload Pattern
Open analysis.request_body_template and inspect it. Below are the exhaustive set of patterns, ordered by detection priority. Pick the first match.
Rule 1: "messages" array exists
payload = {
"messages": messages,
"stream": stream,
}
for k, v in analysis.request_body_template.items():
if k not in ("messages", "stream"):
payload[k] = v
Rule 2: "prompt" is a string field
last = messages[-1]["content"] if messages else ""
if isinstance(last, list):
last = " ".join(p.get("text","") for p in last if p.get("type")=="text")
payload = {"prompt": last, "stream": stream}
for k, v in analysis.request_body_template.items():
if k not in ("prompt", "stream"):
payload[k] = v
Rule 3: "query" is a string field
last = messages[-1]["content"] if messages else ""
payload = {"query": last, "stream": stream}
if "history" in analysis.request_body_template:
payload["history"] = messages[:-1]
for k, v in analysis.request_body_template.items():
if k not in ("query", "history", "stream"):
payload[k] = v
Rule 4: "inputs" is a string field (common in Hugging Face / Gradio)
payload = {"inputs": last, "stream": stream}
for k, v in analysis.request_body_template.items():
if k not in ("inputs", "stream"):
payload[k] = v
Rule 5: Neither messages/prompt/query/inputs — generic fallback
input_field = None
history_field = None
max_len = 0
for k, v in analysis.request_body_template.items():
if isinstance(v, str) and len(v) > max_len:
max_len = len(v)
input_field = k
if isinstance(v, list) and v and isinstance(v[0], dict) and "role" in v[0]:
history_field = k
payload = dict(analysis.request_body_template)
last = messages[-1]["content"] if messages else ""
payload[input_field] = last
if history_field:
payload[history_field] = messages[:-1]
payload["stream"] = stream
Rule 6: WebSocket — analysis.has_websocket == True
The target API uses WebSocket, not HTTP. Do NOT modify adapter.py. Instead, generate ws_adapter.py from templates/ws_adapter.py.
Send frame format — derived from analysis.ws.send_template:
payload = dict(analysis.ws.extra_send_fields)
last = messages[-1]["content"] if messages else ""
if isinstance(last, list):
last = " ".join(p.get("text","") for p in last if p.get("type")=="text")
payload[analysis.ws.input_field] = last
if stream:
payload["stream"] = True
If input_field is "messages", pass the full messages array instead:
payload = dict(analysis.ws.extra_send_fields)
payload["messages"] = messages
if stream:
payload["stream"] = True
Receive extraction — based on analysis.ws.receive_field. Same dot-path logic as Step 2.2.
Init messages — if analysis.ws.init_messages is non-empty, send them in order right after WebSocket connect before the first user message:
await ws.send(json.dumps(init_msg))
Type field filtering — if analysis.ws.type_field is set, skip receive frames whose type is error, ping, pong, ack, status, typing, etc.
1.1 Dynamic parameter passthrough (auto-inferred from HAR)
analysis.supported_params contains every OpenAI-compatible parameter that the target API was observed using in the HAR. Use this list to dynamically decide which kwargs to pass through:
self.supported_params = analysis.supported_params
for param in self.supported_params:
val = kwargs.get(param)
if val is not None:
payload[param] = val
How it works at runtime:
| User sends via OpenAI SDK | analysis.supported_params contains | adapter puts in payload |
|---|
temperature=0.7 | "temperature" | temperature: 0.7 |
temperature=0.7 | not in list | omitted — target doesn't support it |
max_tokens=4096, temperature=0.5 | ["temperature"] | only temperature: 0.5 passed, max_tokens dropped |
stop=["\n\n"], frequency_penalty=0.3 | ["stop"] | only stop: ["\n\n"] passed |
How har_parser.py infers the list:
1. Collect all HAR POST entries whose URL path matches the chat endpoint.
2. For each entry, extract JSON body keys.
3. Match keys against a known alias table:
"temperature" → "temperature"
"max_tokens" → "max_tokens"
"max_length" → "max_tokens" (alias)
"max_new_tokens" → "max_tokens" (alias)
"top_p" → "top_p"
"top_k" → "top_k"
"presence_penalty" → "presence_penalty"
"frequency_penalty" → "frequency_penalty"
"repetition_penalty" → "repetition_penalty"
"stop" → "stop"
"stop_sequences" → "stop" (alias)
"n" → "n"
"seed" → "seed"
"user" → "user"
4. If a key appears in any request with a non-null, non-zero value,
its canonical name is added to supported_params.
This is zero-cost: it reads what the browser already sent, without sending any extra probe requests.
Verification (optional, added to Step 6):
if "temperature" in adapter.supported_params:
cold = await _send_with_temp(adapter, 0.0)
hot = await _send_with_temp(adapter, 2.0)
if len(cold) == len(hot) and cold == hot:
print("[WARN] temperature set but responses identical — target may ignore it")
1.2 Inject DSML for tool calling (if applicable)
if tools and self.dsml_enabled and self.dsml_ready:
if tool_choice != "none":
dsml_prompt = build_dsml_tool_prompt(tools, tool_choice)
messages = self._inject_dsml_prompt(messages, dsml_prompt)
Place this before the payload construction so messages is already modified.
1.3 Building ws_adapter.py (only when has_websocket)
If analysis.has_websocket is True, generate ws_adapter.py from templates/ws_adapter.py. Do NOT modify adapter.py.
Modification table — map each analysis.ws.* field to the corresponding WebSocketChatAdapter attribute:
ws_adapter.py attribute/method | Set from analysis.ws.* |
|---|
self.ws_url | analysis.ws.ws_url |
self.send_template | analysis.ws.send_template |
self.input_field | analysis.ws.input_field |
self.receive_field | analysis.ws.receive_field |
self.receive_is_streaming | analysis.ws.receive_is_streaming |
self.type_field | analysis.ws.type_field |
self.extra_send_fields | analysis.ws.extra_send_fields |
self.init_messages | analysis.ws.init_messages |
_connect() | Override only if target requires custom subprotocols |
_extract_content_from_frame() | Uses receive_field + type_field — no change needed |
_is_done_frame() | Override only if done signal differs from defaults |
The template already uses self.input_field and self.extra_send_fields in convert_request() — just fill them in __init__ and it works.
Key behavioral differences from HTTP adapter to note:
- Connection lifecycle: Each request opens a fresh WS connection (connect → send → receive → close). If the HAR shows multiple send/receive pairs on one WS (persistent session), change to reuse the connection.
- Init messages: If
init_messages is non-empty, _connect() automatically sends them after connecting. No additional code needed.
- Frame done detection:
_is_done_frame() checks multiple patterns (finish_reason, done flag, type field, [DONE]). If the target uses a unique signal, override this method.
- Non-streaming mode: When
receive_is_streaming is False, the adapter reads exactly one receive frame and returns it. When True, it reads frames until _is_done_frame() returns True.
- Binary frames: WebSocketChatAdapter only handles text frames (opcode 1). Binary frames (opcode 2) are not supported — report this limitation.
2.1 Non-streaming response
analysis.is_streaming is False. The HAR response is JSON.
Extract analysis.content_field_path which is a dot-path like:
"answer" → data["answer"]
"choices[0].message.content" → data["choices"][0]["message"]["content"]
"data.text" → data["data"]["text"]
Modify _extract_content_from_json:
def _extract_content_from_json(self, data: dict) -> Optional[str]:
"""Navigate the content_field_path to extract the AI response text."""
path = analysis.content_field_path
if path:
import functools
try:
parts = path.replace("[", ".").replace("]", "").split(".")
val = functools.reduce(lambda d, k: d[int(k) if k.isdigit() else k] if isinstance(d, (dict, list)) else None, parts, data)
if isinstance(val, str) and val.strip():
return val
except (KeyError, IndexError, TypeError):
pass
for key in ["answer", "text", "content", "reply", "response", "output", "completion", "result"]:
val = data.get(key)
if isinstance(val, str) and val.strip():
return val
def deepest(obj):
if isinstance(obj, dict):
for v in obj.values():
r = deepest(v)
if r: return r
elif isinstance(obj, list):
for v in obj:
r = deepest(v)
if r: return r
elif isinstance(obj, str) and len(obj) > 20:
return obj
return None
return deepest(data)
2.2 Streaming response (SSE)
analysis.is_streaming is True. The HAR response has Content-Type: text/event-stream.
Modify _extract_content_from_data according to the table below. Only keep the matching branch + one generic fallback. Delete all other branches.
sse_format | sse_data_field | Code to write in _extract_content_from_data |
|---|
"plain_token" | "v" | return data.get("v") if isinstance(data.get("v"), str) else None |
"plain_token" | "content" | return data.get("content") if isinstance(data.get("content"), str) else None |
"plain_token" | "text" | return data.get("text") if isinstance(data.get("text"), str) else None |
"plain_token" | "delta" | d = data.get("delta"); return d.get("content") if isinstance(d, dict) else (d if isinstance(d, str) else None) |
"plain_token" | "token" | return data.get("token") if isinstance(data.get("token"), str) else None |
"plain_token" | "response" | return data.get("response") if isinstance(data.get("response"), str) else None |
"path_op_value" | "v" | DeepSeek: return data.get("v") if data.get("o") == "APPEND" else None |
"nested" | "v.content" | v = data.get("v", {}); return v.get("content") or v.get("response", {}).get("content") |
"nested" | "delta.content" | d = data.get("delta", {}); return d.get("content") |
"nested" | "choices[0].delta.content" | c = data.get("choices", []); return c[0].get("delta", {}).get("content") if c else None |
"raw_text" | — | SSE data line is raw text, not JSON: return data if isinstance(data, str) else None |
Fallback that always stays at the end:
if isinstance(data, dict):
for key in ("content", "text", "answer", "v", "response", "token", "delta"):
val = data.get(key)
if isinstance(val, str) and val:
return val
if isinstance(val, dict):
sub = val.get("content") or val.get("text")
if sub:
return sub
return None
2.3 Modify convert_response
Replace the hardcoded field lookup:
content = response.get("answer") or response.get("text") or json.dumps(response)
content = self._extract_content_from_json(response)
if not content:
content = json.dumps(response, ensure_ascii=False)
Step 3 — Configure Auth
Before configuring auth, check references/bypass-guides/ for detailed techniques on handling CAPTCHA, WAF, and PoW challenges. The guides contain ready-to-use code snippets and detection methods.
3.1 None (most common)
3.2 Authorization header
if analysis.auth_header:
self.headers["Authorization"] = analysis.auth_header
3.3 PoW (DeepSeek pattern)
self.auth_type = "pow"
self._challenge_endpoint = analysis.pow_endpoint
def _ensure_auth_headers(self) -> dict:
if self.auth_type != "pow":
return {}
challenge = self._fetch_challenge()
answer = self._solve_challenge(challenge)
return {"X-DS-PoW-Response": self._encode_pow_answer(challenge, answer)}
def _fetch_challenge(self) -> dict:
resp = httpx.post(
f"{self.base_url}{self._challenge_endpoint}",
json={"target_path": "/api/v0/chat/completion"},
headers=self.headers,
timeout=15,
)
return resp.json()
def _solve_challenge(self, challenge_data: dict) -> int:
biz = challenge_data.get("data", {}).get("biz_data", {}).get("challenge", challenge_data)
import hashlib
salt = biz["salt"]
expire_at = biz["expire_at"]
difficulty = biz["difficulty"]
target = biz.get("target_path", "")
nonce = 0
while True:
h = hashlib.sha256(f"{salt}{expire_at}{nonce}{target}".encode()).hexdigest()
if h.startswith("0" * difficulty):
return nonce
nonce += 1
3.4 Token refresh
self.auth_type = "token_refresh"
Step 4 — Construct Headers
The __init__ must produce headers identical to what the browser sent:
def __init__(self, cookies: str, base_url: str, dsml_enabled: bool = True):
self.headers = {}
for key, val in analysis.headers.items():
if key.lower() != "content-length":
self.headers[key] = val
if "User-Agent" not in self.headers:
self.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
if "Content-Type" not in self.headers:
self.headers["Content-Type"] = "application/json"
if analysis.auth_header:
self.headers["Authorization"] = analysis.auth_header
self.base_url = base_url.rstrip("/")
self.chat_endpoint = analysis.chat_endpoint
self.auth_type = analysis.auth_type
self.dsml_enabled = dsml_enabled
self.dsml_ready = False
Step 5 — Session State (if needed)
Some APIs require state across requests (session ID, chat session ID).
Check analysis.request_body_template or analysis.ws.send_template for keys like:
chat_session_id, session_id, conversation_id, conversationId
For HTTP adapter
def __init__(self, ...):
...
self.chat_session_id = analysis.request_body_template.get("chat_session_id")
self.conversation_id = analysis.request_body_template.get("conversation_id")
self._auto_update_session = "chat_session_id" in analysis.request_body_template
def convert_request(self, ...):
...
payload["chat_session_id"] = self.chat_session_id
For WebSocket adapter (persistent connection)
If the HAR shows multiple send/receive pairs on the same WebSocket (check if _webSocketMessages has alternating send/receive across the whole entry), the target expects a persistent connection. Modify WebSocketChatAdapter to keep the WS open:
def __init__(self, ...):
...
self._ws = None
self._session_id = analysis.ws.extra_send_fields.get("chat_session_id")
self._auto_update = "chat_session_id" in analysis.ws.extra_send_fields
async def _get_connection(self):
"""Reuse or create a persistent WebSocket."""
if self._ws is None or self._ws.closed:
self._ws = await self._connect()
return self._ws
Then replace _connect() calls in send_request/stream_request with _get_connection(). This avoids reconnecting and re-sending init messages for every request.
Step 6 — Generate config_tool.py
Copy templates/config_tool.py → output config_tool.py. This is a standalone Tkinter GUI that lets the user re-configure .env from any HAR file without re-running the AI.
6.1 Fill FIELD_LABELS
Map every key in the info dict (from parse_har_file()) to a Chinese label:
FIELD_LABELS = {
"base_url": "目标地址",
"chat_endpoint": "聊天端点",
"cookies": "Cookie",
"auth_header": "Authorization",
"auth_type": "认证类型",
"is_streaming": "流式支持",
"has_websocket": "WebSocket",
"has_pow": "PoW 挑战",
"content_field_path": "内容字段路径",
"supported_params": "支持的参数",
}
Only include keys that exist in analysis. If the target has unique fields (DeepSeek's chat_session_id, Gemini's bl/f.sid/at/sn), add them here.
6.2 Fill ENV_MAPPING
Map (env_key, info_key, comment) for every field that should appear in .env:
ENV_MAPPING = [
("HAR_PATH", "har_path", "HAR 文件路径(用于重新解析)"),
("TARGET_URL", "base_url", "目标网站地址"),
("CHAT_ENDPOINT", "chat_endpoint", "聊天 API 端点路径"),
("COOKIES", "cookies", "登录 Cookie"),
("AUTH_HEADER", "auth_header", "Authorization 令牌"),
("AUTH_TYPE", "auth_type", "认证类型"),
("STREAMING", "is_streaming", "是否支持流式输出"),
("WEBSOCKET", "has_websocket", "是否使用 WebSocket"),
]
6.3 Fill DISPLAY_FIELDS
Control which fields appear in the TreeView and in what order:
DISPLAY_FIELDS = [
("目标地址", "base_url"),
("聊天端点", "chat_endpoint"),
("认证类型", "auth_type"),
("Cookie", "cookies"),
("Authorization", "auth_header"),
]
6.4 Customize parse_har_file() if needed
The default implementation calls har_parser.parse_har() and extracts common fields. If the target has unique parameters not covered by har_parser.py, add extraction logic in parse_har_file() or use the _extract_raw_har() helper which can regex-search the raw HAR text for specific keys.
Example for DeepSeek (adds chat_session_id):
def parse_har_file(har_path: str) -> dict:
info = _default_parse(har_path)
chat_session_id = _extract_raw_har(har_path, "chat_session_id")
if chat_session_id:
info["chat_session_id"] = chat_session_id
FIELD_LABELS["chat_session_id"] = "会话 ID"
ENV_MAPPING.append(("CHAT_SESSION_ID", "chat_session_id", "DeepSeek 会话 ID"))
DISPLAY_FIELDS.append(("会话 ID", "chat_session_id"))
return info
6.5 Fill MUTABLE_KEYS — the split design
This is the key innovation. .env is split into two sections. AI decides which keys go where based on the nature of each field:
放入 MUTABLE_KEYS(异变) | 不放入(不易变) |
|---|
| Cookie、Authorization 等会过期的鉴权凭证 | PORT、HOST 等服务器监听配置 |
| Token、session_id 等每次登录变化的参数 | MODEL_NAME、API_KEY 等固定标识 |
| TARGET_URL、CHAT_ENDPOINT(站点结构可能变) | DSML_ENABLED 等功能开关 |
| STREAMING、WEBSOCKET 等传输方式标记 | 用户自定义的运行时参数 |
| 从 HAR 中解析出的任何易变字段 | 不会在 Cookie 过期时一起变化的值 |
规则:凡是会随登录过期/变化的 key → 放入 MUTABLE_KEYS;凡是服务器本身的配置 → 不放入。
运行时 merge_env_with_auth() 的行为:
- 读取现有
.env
- 只覆写
MUTABLE_KEYS 中的 key
- 其他 key(PORT、MODEL_NAME 等)保留原值
- 如果
.env 不存在,不易变部分用 DEFAULT_IMMUTABLE 填充
示例输出 .env:
# ============================================
# 不易变部分 — 服务器配置(配置工具不会修改)
# ============================================
MODEL_NAME=gpt-4o
HOST=0.0.0.0
PORT=8000
...
# ============================================
# 异变部分 — 账号鉴权凭证(配置工具只更新此段)
# ============================================
COOKIES=...
AUTH_HEADER=...
6.6 Deliver
Add to the output deliverables. The user can double-click config_tool.py to:
- Select a HAR file from disk
- Click "解析" to extract all config fields
- View the generated .env in a syntax-highlighted preview
- Click "保存到 .env" to persist
- Click "启动代理服务器" to run
server.py
When Cookie expires, the user just re-opens config_tool.py, selects the same HAR (or a fresh one), clicks "解析" → "保存" → "启动", all without touching the AI.
Step 7 — Verify with Real Requests
Run this exact verification protocol, in order:
6.1 Non-streaming test
import httpx
async def verify_non_streaming():
payload = adapter.convert_request(
[{"role": "user", "content": "Hello"}],
stream=False,
)
async with httpx.AsyncClient(headers=adapter.headers, timeout=30) as client:
resp = await client.post(
f"{adapter.base_url}{adapter.chat_endpoint}",
json=payload,
)
assert resp.status_code == 200, f"Non-streaming: status {resp.status_code}"
data = resp.json()
content = adapter._extract_content_from_json(data)
assert content and len(content) > 0, f"Non-streaming: empty content from {list(data.keys())}"
print(f"[OK] Non-streaming: got {len(content)} chars")
return True
6.2 Streaming test
async def verify_streaming():
payload = adapter.convert_request(
[{"role": "user", "content": "Hello"}],
stream=True,
)
chunks = []
async with httpx.AsyncClient(headers=adapter.headers, timeout=30) as client:
async with client.stream("POST", f"{adapter.base_url}{adapter.chat_endpoint}", json=payload) as resp:
assert resp.status_code == 200, f"Streaming: status {resp.status_code}"
async for line in resp.aiter_lines():
line = line.strip()
if not line: continue
if line.startswith("data: "):
raw = line[6:]
if raw.strip() == "[DONE]": break
chunks.append(raw)
assert len(chunks) > 0, "Streaming: no data chunks received"
print(f"[OK] Streaming: got {len(chunks)} chunks")
return True
6.3 WebSocket verification (only when has_websocket)
import websockets
async def verify_ws_non_streaming():
payload = adapter.convert_request(
[{"role": "user", "content": "Hello"}],
stream=False,
)
ws = await adapter._connect()
try:
await ws.send(json.dumps(payload, ensure_ascii=False))
frames = []
if adapter.receive_is_streaming:
async for raw in ws:
text = adapter._extract_content_from_frame(raw)
if text:
frames.append(text)
if adapter._is_done_frame(raw):
break
else:
raw = await ws.recv()
text = adapter._extract_content_from_frame(raw)
if text:
frames.append(text)
assert len(frames) > 0, "WS non-streaming: no content frames"
print(f"[OK] WS non-streaming: got {len(frames)} frames")
return True
finally:
await ws.close()
async def verify_ws_streaming():
if not adapter.receive_is_streaming:
print("[SKIP] WS streaming: target is non-streaming")
return True
payload = adapter.convert_request(
[{"role": "user", "content": "Hello"}],
stream=True,
)
ws = await adapter._connect()
try:
await ws.send(json.dumps(payload, ensure_ascii=False))
chunks = []
async for raw in ws:
text = adapter._extract_content_from_frame(raw)
if text:
chunks.append(text)
if adapter._is_done_frame(raw):
break
assert len(chunks) > 1, f"WS streaming: only {len(chunks)} chunks (need >1)"
print(f"[OK] WS streaming: got {len(chunks)} chunks")
return True
finally:
await ws.close()
6.4 Failure recovery
| Failure | Root cause | Fix |
|---|
| Status 401/403 | Cookie expired or auth missing | Tell user Cookie expired, ask for fresh HAR |
| Status 404 | Wrong endpoint | Check analysis.chat_endpoint; try common alternatives |
| Status 400 | Wrong payload shape | Compare generated payload against analysis.request_body_template byte-for-byte |
| Empty content in JSON | Wrong content field path | Re-scan JSON keys; pick the longest string value path |
| No SSE chunks | Wrong streaming mode or field | Read raw HAR SSE text; manually identify which key holds text |
| Connection refused | Target blocked by CORS/geo | Add Origin and Referer headers from HAR |
| WS connection refused | Wrong URL scheme/port | Check analysis.ws.ws_url; try both wss:// and ws:// |
| WS closed immediately | Auth/init messages missing | Check analysis.ws.init_messages and verify they replay correctly |
| WS receive empty | Wrong receive_field | Re-scan HAR receive frame JSON keys; pick the longest string value |
| WS stream stuck | Wrong done signal | Read HAR receive frames manually; find what signals completion |
| WS binary frame | Target sends opcode 2 | Cannot support — tell user this AI uses binary WS frames |
Max 3 retry rounds. After each round, adjust and re-run verification. If still failing after 3, output what was tried and ask user for a fresh HAR file.
Step 8 — Start Proxy & Final Test
pip install fastapi uvicorn httpx python-dotenv websockets
python server.py &
sleep 2
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}],"stream":false}' \
| python -c "import sys,json; d=json.load(sys.stdin); print('OK:', d['choices'][0]['message']['content'][:80])"
curl -s -N http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}],"stream":true}' \
| head -5
Step 9 — Deliverables
adapter.py — modified HTTP adapter (if HTTP detected)
ws_adapter.py — WebSocket adapter (if has_websocket is True)
config_tool.py — GUI configurator (HAR → .env, with Cookie update support)
server.py — from template (no changes needed)
.env.example:
TARGET_URL=https://chat.example.com
COOKIES=...
MODEL_NAME=gpt-4o
HOST=0.0.0.0
PORT=8000
API_KEY=sk-web2api-placeholder
DSML_ENABLED=true
requirements.txt: fastapi, uvicorn, httpx, python-dotenv, pydantic, websockets
- Start command and E2E verification output
- Integration guide:
OPENAI_API_BASE=http://localhost:8000/v1
HAR Parser Field Reference
| Field | Type | Source | Example |
|---|
base_url | str | Scheme + netloc of matched entry | https://chat.example.com |
chat_endpoint | str | URL path + query | /api/v0/chat/completion |
headers | dict | All request headers | {Cookie: ..., Origin: ...} |
cookies | str | Cookie header value | __session=abc; token=xyz |
auth_header | Optional[str] | Authorization header | Bearer eyJ... |
auth_type | str | "none" / "pow" / "oauth" | — |
request_body_template | dict | Parsed POST body | {messages: [...], stream: true} |
content_field_path | str | Deepest non-streaming content field | choices[0].message.content |
is_streaming | bool | text/event-stream in Content-Type | — |
sse_event_type | str | SSE event: lines | "append" or "" |
sse_data_field | str | Key inside SSE data JSON | "v" / "content" / "delta" |
sse_format | str | Structure of SSE data payload | "plain_token" / "path_op_value" / "nested" / "raw_text" |
has_pow | bool | Challenge endpoint in HAR | — |
pow_endpoint | str | PoW challenge URL path | /api/v0/chat/create_pow_challenge |
all_endpoints | list[str] | All unique URL paths in HAR | — |
supported_params | list[str] | OpenAI params seen in HAR request bodies | ["temperature", "top_p"] |
chat_entry_index | int | Index in HAR entries list | 42 |
has_websocket | bool | WebSocket entry detected in HAR | True or False |
ws.ws_url | str | WebSocket URL (wss:// or ws://) | wss://chat.example.com/ws |
ws.send_template | dict | JSON of first WS send frame | {type: "chat", content: ""} |
ws.input_field | str | Key in send_template for user msg | "content" / "messages" |
ws.receive_field | str | Key in receive frames for AI text | "content" / "answer" |
ws.receive_is_streaming | bool | Multiple receives per single send | True / False |
ws.type_field | str | Frame type discriminator key | "type" / "event" / "" |
ws.extra_send_fields | dict | Non-input keys to carry in every send | {type: "chat", id: 1} |
ws.init_messages | list[dict] | Auth/init frames to replay on connect | [{type: "auth", ...}] |
SSE Format Detection Guide
har_parser.py classifies SSE data into these formats. Understand them to write correct extraction code:
"plain_token"
Each data: line contains a JSON object. One key holds the incremental text directly.
→ The value at sse_data_field is a plain string.
"path_op_value" (DeepSeek)
Each data: line has p (path), o (operation), v (value). Text arrives as APPEND operations.
→ Only extract when o == "APPEND", take v.
"nested"
The value at sse_data_field is a dict, and content is nested deeper.
→ Navigate one level deeper: data[sse_field]["content"].
"raw_text"
The data: line content is not JSON at all — it's a plain string.
// data: Hello
// data: world
→ The content IS the raw string itself.
Common Pattern Reference
| Type | Endpoint | Payload | Content Field | SSE |
|---|
| DeepSeek Chat | /api/v0/chat/completion | {chat_session_id, prompt, stream} | APPEND v | path_op_value |
| ChatGPT Next Web | /api/chat | {messages, model} | choices[0].message.content | nested / choices[0].delta.content |
| ChatGPT Official | /backend-api/conversation | {action, messages} | message.content.parts[0] | nested |
| LobeChat | /api/chat | {messages, model} | choices[0].delta.content | nested |
| Open WebUI | /chat/completions | {messages, model} | OpenAI standard | nested |
| Gradio Chat | /api/chat | {data: [...]} | data[0] | raw_text or plain_token |
| Custom NextJS | /api/chat | {prompt, history} | text / answer | varies |
| WebSocket Generic | wss://.../ws | send: {content: "..."} / {messages: [...]} | receive content | receive_is_streaming auto-detected |
DSML Tool Calling Reference
DSML injects XML-style instructions into the system prompt to support tool calling on models without native function calling.
The tool_dsml.py template now includes these capabilities:
| Function | Purpose |
|---|
strip_dsml_markup() | Remove ` |
parse_dsml_invoke() | Parse DSML → OpenAI tool_calls (handles both `< |
format_tool_calls_for_prompt() | Convert OpenAI tool_calls back to DSML for history messages |
build_dsml_tool_prompt() | Generate DSML instruction prompt from tool definitions |
strip_dsml_tags() | Clean all DSML/XML markup from text |
_auto_type() | Smart type inference (string "42" → int 42, "true" → True) |
_cdata() | CDATA wrapping with proper ]]> escaping |
has_dsml_content() | Quick check for DSML tags (supports both prefixed and bare formats) |
Multi-turn tool calls: When building prompts for multi-turn conversations, format_tool_calls_for_prompt() converts previous tool_calls back to DSML format so the model sees the complete history.
Tags
| Tag | Purpose |
|---|
<|DSML|tool_calls> | Root container for one or more invocations |
<|DSML|invoke name="fn_name"> | Single tool call start |
<|DSML|parameter name="key"><![CDATA[value]]></|DSML|parameter> | Key-value parameter |
</|DSML|invoke> | Tool call end |
</|DSML|tool_calls> | Root end |
How it works
messages_with_dsml = adapter._inject_dsml_prompt(messages, tools, tool_choice)
The tool_sieve.py template now features:
| Feature | Description |
|---|
| Multi-tag detection | Recognizes <|DSML|tool_calls>, <tool_calls>, <invoke, etc. |
_split_safe() | Prevents mid-tag cut-off by checking if trailing chars match a known tag prefix |
| Dual-buffer design | _pending buffer for safe text, _capture_buf for tool call capture |
| Event model | SieveEvent with type='text' or type='tool_calls' for structured output |
| Flush fallback | Incomplete captures are flushed as plain text at stream end |
StreamSieve handles: split TCP chunks, embedded CDATA, mixed text+tools, abrupt stream end.
tool_choice mapping: "auto" → optional, "none" → skip, "required" → force.
PoW Solving Reference
When has_pow is True (DeepSeek pattern):
- PRE-request:
POST /api/v0/chat/create_pow_challenge → returns {data: {biz_data: {challenge: {algorithm, challenge, salt, expire_at, difficulty, signature, target_path}}}}
- Solve: find nonce such that
sha256(salt + expire_at + nonce + target_path) starts with difficulty zero hex chars
- Encode:
base64({algorithm, challenge, salt, answer, signature, target_path})
- Send as
X-DS-PoW-Response header with the main chat request
If WASM solver binary is available from the target site, use wasmtime instead of Python hashlib for performance.
Limitations
- Text-only: no multimodal (images/files)
- Tool calling via DSML injection, not native function calling — depends on model's XML comprehension
seed / response_format / json_mode not supported
- Parameters (
temperature, max_tokens, ...) automatically detected from HAR — only those the target API actually uses will be passed through
- PoW solver is site-specific (DeepSeek pattern)
- Cookie-based auth expires; user must refresh HAR when needed
- WebSocket only supports text frames (opcode 1) — binary frames not supported
- WebSocket adapter requires
websockets library (added to requirements)
- WS init messages are replayed on every new connection — ensure they are idempotent