feat: add DeepSeek Harness client support (#430)

* feat: add DeepSeek Harness client

* fix: harden dsh forward proxy capture

---------

Co-authored-by: WEIFENG2333 <fengeto@gmail.com>
This commit is contained in:
BKK
2026-08-13 21:40:22 +08:00
committed by GitHub
parent bf1a37b7c5
commit dcb1d42dae
12 changed files with 445 additions and 14 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+26 -3
View File
@@ -64,7 +64,7 @@ It works with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Co
- 🔎 **Debug behavior with evidence**: compare adjacent requests and pinpoint which prompt, message, tool, or parameter changed.
- 📦 **Share one portable artifact**: each run writes a local trace session that can be exported to a self-contained HTML viewer for review or archiving.
- 🔒 **Keep traces on your machine**: no hosted dashboard is required, and common auth headers are redacted before recording.
- 🧩 **Use one workflow across clients**: trace Claude Code, Codex CLI, Codex App, Gemini CLI, Grok Build CLI, Kimi CLI, MiMo Code, OpenCode, OpenClaw, Pi, Hermes Agent, Cursor CLI, Qoder CLI, and CodeBuddy.
- 🧩 **Use one workflow across clients**: trace Claude Code, Codex CLI, Codex App, Gemini CLI, Grok Build CLI, DeepSeek Harness, Kimi CLI, MiMo Code, OpenCode, OpenClaw, Pi, Hermes Agent, Cursor CLI, Qoder CLI, and CodeBuddy.
## Supported Clients
@@ -75,6 +75,7 @@ It works with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Co
| [Codex App](https://openai.com/codex/) | Desktop app launched through forward proxy mode so backend HTTP/WebSocket request bodies are captured |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google OAuth / Code Assist traffic |
| [Grok Build CLI](https://docs.x.ai/build/overview) | Grok subscription OAuth sessions through the official CLI chat proxy |
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` headless tasks and custom profiles using DeepSeek or compatible gateways |
| [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | Legacy kimi-cli and the newer Kimi Code CLI |
| [MiMo Code](https://mimo.xiaomi.com/en/mimocode) | MiMo Code sessions (OpenCode fork with multi-provider support) |
| [OpenCode](https://opencode.ai) | Multi-provider OpenCode sessions |
@@ -123,6 +124,9 @@ claude-tap --tap-client gemini -- -p "hello"
# Grok Build CLI
claude-tap --tap-client grok -- -p "hello"
# DeepSeek Harness headless task
claude-tap --tap-client dsh -- --profile headless "Reply OK"
# Kimi CLI
claude-tap --tap-client kimi
@@ -525,6 +529,25 @@ GROK_CLI_CHAT_PROXY_BASE_URL=https://grok-gateway.example.com/v1 \
</details>
<details>
<summary>DeepSeek Harness examples</summary>
DeepSeek Harness (`dsh`) uses forward proxy mode by default. This captures model traffic whether the endpoint comes from `DEEPSEEK_BASE_URL` or a stored dsh model setting, including loopback gateways normally covered by `NO_PROXY`. The launcher verifies that Node supports `--use-env-proxy`, records only Chat Completions traffic, and passes all arguments after `--` to dsh unchanged. If that Node capability is unavailable, upgrade Node or use reverse mode with an environment-configured endpoint.
```bash
# One-shot headless task
claude-tap --tap-client dsh -- --profile headless "Summarize this repository"
# Custom dsh profile
claude-tap --tap-client dsh -- --profile my-profile
# Reverse mode for deployments configured only through DEEPSEEK_BASE_URL
claude-tap --tap-client dsh --tap-proxy-mode reverse \
-- --profile headless "Reply OK"
```
</details>
<details>
<summary>CodeBuddy CLI examples</summary>
@@ -604,7 +627,7 @@ By default the launcher points at the current checkout; pass `--installed` if `c
All flags are forwarded to the selected client, except these `--tap-*` ones:
```
--tap-client CLIENT Client to launch/listen to: claude (default), agy, codex, codexapp, gemini, grok, kimi, kimi-code, mimo, opencode, openclaw, pi, hermes, cursor, qoder, or codebuddy
--tap-client CLIENT Client to launch/listen to: claude (default), agy, codex, codexapp, dsh, gemini, grok, kimi, kimi-code, mimo, opencode, openclaw, pi, hermes, cursor, qoder, or codebuddy
--tap-target URL Upstream API URL (default: auto per client)
--tap-live Start real-time viewer while the client runs (default: on)
--tap-no-live Disable the real-time viewer server (pre-v0.1.75 behavior)
@@ -616,7 +639,7 @@ All flags are forwarded to the selected client, except these `--tap-*` ones:
--tap-no-launch Only start the proxy, don't launch client
--tap-max-traces N Max trace sessions to keep (default: 50, 0 = unlimited)
--tap-store-stream-events Persist raw SSE/WebSocket event arrays during capture so viewer/export output can show them (default: off)
--tap-proxy-mode MODE Proxy mode: reverse or forward (default: reverse for claude/codex/grok/kimi/kimi-code/openclaw/codebuddy, forward for agy/codexapp/gemini/mimo/opencode/pi/hermes/qoder; cursor is transcript-only and ignores proxy MITM)
--tap-proxy-mode MODE Proxy mode: reverse or forward (default: reverse for claude/codex/grok/kimi/kimi-code/openclaw/codebuddy, forward for agy/codexapp/dsh/gemini/mimo/opencode/pi/hermes/qoder; cursor is transcript-only and ignores proxy MITM)
--tap-trust-ca On macOS, explicitly trust the local CA in the user login keychain before launch (agy does this automatically)
```
+26 -3
View File
@@ -64,7 +64,7 @@
- 🔎 **用证据定位问题**:对比相邻请求,明确是哪段 prompt、消息、工具或参数发生了变化。
- 📦 **留下可分享证据**:每次运行都会写入 JSONL trace,并生成自包含 HTML 查看器,方便 review 或归档。
- 🔒 **数据留在本机**:不依赖云端 dashboard;常见认证 header 会在记录前自动脱敏。
- 🧩 **覆盖主流编码客户端**:同一套流程可用于 Claude Code、Codex CLI、Codex App、Gemini CLI、Grok Build CLI、Kimi CLI、MiMo Code、OpenCode、OpenClaw、Pi、Hermes Agent、Cursor CLI、Qoder CLI、Antigravity CLI 和 CodeBuddy CLI。
- 🧩 **覆盖主流编码客户端**:同一套流程可用于 Claude Code、Codex CLI、Codex App、Gemini CLI、Grok Build CLI、DeepSeek Harness、Kimi CLI、MiMo Code、OpenCode、OpenClaw、Pi、Hermes Agent、Cursor CLI、Qoder CLI、Antigravity CLI 和 CodeBuddy CLI。
## 支持的客户端
@@ -75,6 +75,7 @@
| [Codex App](https://openai.com/codex/) | 通过 forward proxy 启动桌面 App,捕获后端 HTTP/WebSocket 请求体 |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google OAuth / Code Assist 的多 Google 端点流量 |
| [Grok Build CLI](https://docs.x.ai/build/overview) | 通过官方 CLI chat proxy 捕获 Grok 订阅 OAuth 会话 |
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | 使用 DeepSeek 或兼容网关的 `dsh` headless 任务和自定义 profile |
| [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | 旧版 kimi-cli 和新版 Kimi Code CLI |
| [MiMo Code](https://mimo.xiaomi.com/en/mimocode) | MiMo Code 会话(基于 OpenCode 的多提供方 fork |
| [OpenCode](https://opencode.ai) | 多提供方 OpenCode 会话 |
@@ -123,6 +124,9 @@ claude-tap --tap-client gemini -- -p "hello"
# Grok Build CLI
claude-tap --tap-client grok -- -p "hello"
# DeepSeek Harness headless 任务
claude-tap --tap-client dsh -- --profile headless "Reply OK"
# Kimi CLI
claude-tap --tap-client kimi
@@ -517,6 +521,25 @@ GROK_CLI_CHAT_PROXY_BASE_URL=https://grok-gateway.example.com/v1 \
</details>
<details>
<summary>DeepSeek Harness 示例</summary>
DeepSeek Harness`dsh`)默认使用 forward proxy。无论模型端点来自 `DEEPSEEK_BASE_URL`,还是保存在 dsh 模型设置中,都可以捕获,包括通常会被 `NO_PROXY` 绕过的本地网关。启动器会确认 Node 支持 `--use-env-proxy`,仅记录 Chat Completions 流量,并将 `--` 后的参数原样传给 dsh。如果当前 Node 不支持该能力,请升级 Node,或对环境变量配置的端点使用 reverse 模式。
```bash
# 一次性 headless 任务
claude-tap --tap-client dsh -- --profile headless "Summarize this repository"
# 自定义 dsh profile
claude-tap --tap-client dsh -- --profile my-profile
# 仅通过 DEEPSEEK_BASE_URL 配置的部署也可显式使用 reverse 模式
claude-tap --tap-client dsh --tap-proxy-mode reverse \
-- --profile headless "Reply OK"
```
</details>
<details>
<summary>CodeBuddy CLI 示例</summary>
@@ -595,7 +618,7 @@ macOS 上,`claude-tap build-macos-app` 会生成本地 `Claude Tap.app`。该
除以下 `--tap-*` 参数外,所有参数均透传给所选客户端:
```
--tap-client CLIENT 启动或监听的客户端: claude(默认)/ agy / codex / codexapp / gemini / grok / kimi / kimi-code / mimo / opencode / openclaw / pi / hermes / cursor / qoder / codebuddy
--tap-client CLIENT 启动或监听的客户端: claude(默认)/ agy / codex / codexapp / dsh / gemini / grok / kimi / kimi-code / mimo / opencode / openclaw / pi / hermes / cursor / qoder / codebuddy
--tap-target URL 上游 API 地址(默认: 根据客户端自动选择)
--tap-live 客户端运行时启动实时查看器(默认开启)
--tap-no-live 关闭实时查看器(恢复 v0.1.75 之前的行为)
@@ -607,7 +630,7 @@ macOS 上,`claude-tap build-macos-app` 会生成本地 `Claude Tap.app`。该
--tap-no-launch 仅启动代理,不启动客户端
--tap-max-traces N 最大保留 trace 数量(默认: 50,0 = 不限)
--tap-store-stream-events 捕获时把原始 SSE/WebSocket event 数组写入 trace 存储,以便查看器/导出结果展示(默认关闭)
--tap-proxy-mode MODE 代理模式: reverse 或 forward(默认:claude/codex/grok/kimi/kimi-code/openclaw/codebuddy 用 reverseagy/codexapp/gemini/mimo/opencode/pi/hermes/qoder 用 forwardcursor 为 transcript-only,不走 MITM 代理)
--tap-proxy-mode MODE 代理模式: reverse 或 forward(默认:claude/codex/grok/kimi/kimi-code/openclaw/codebuddy 用 reverseagy/codexapp/dsh/gemini/mimo/opencode/pi/hermes/qoder 用 forwardcursor 为 transcript-only,不走 MITM 代理)
--tap-trust-ca macOS 上显式把本地 CA 信任到当前用户 login keychainagy 会自动执行)
```
+6 -1
View File
@@ -481,6 +481,7 @@ async def async_main(args: argparse.Namespace):
].forward_base_url_allowed_path_prefixes,
trace_methods=CLIENT_CONFIGS[args.client].forward_trace_methods,
trace_path_prefixes=CLIENT_CONFIGS[args.client].forward_trace_path_prefixes,
trace_path_suffixes=CLIENT_CONFIGS[args.client].forward_trace_path_suffixes,
store_stream_events=args.store_stream_events,
capture_only=capture_only,
)
@@ -735,6 +736,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
' claude-tap --tap-client grok -- -p "hello"\n'
" # Authenticate first with `grok login`\n"
"\n"
"deepseek harness (forward proxy mode):\n"
' claude-tap --tap-client dsh -- --profile headless "Reply OK"\n'
" # Forward mode captures both stored and environment-configured DeepSeek endpoints\n"
"\n"
"opencode (multi-provider; defaults to forward proxy mode):\n"
" # Forward proxy captures every provider opencode talks to\n"
" claude-tap --tap-client opencode\n"
@@ -846,7 +851,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help=(
"'reverse' sets provider base URL, 'forward' sets HTTPS_PROXY with CONNECT/TLS termination. "
"Default depends on the client: 'reverse' for claude/codex/grok/kimi/kimi-code/openclaw/codebuddy, "
"'forward' for agy/codexapp/gemini/mimo/opencode/pi/hermes/qoder. "
"'forward' for agy/codexapp/dsh/gemini/mimo/opencode/pi/hermes/qoder. "
"Ignored for transcript-only clients such as cursor."
),
)
+63 -4
View File
@@ -328,6 +328,7 @@ class ClientConfig:
# product traffic can still relay everything while persisting only model API calls.
forward_trace_methods: tuple[str, ...] = ()
forward_trace_path_prefixes: tuple[str, ...] = ()
forward_trace_path_suffixes: tuple[str, ...] = ()
# Transcript-only clients are observed from local session logs instead of a
# spawned process and do not need a reverse or forward proxy.
transcript_only: bool = False
@@ -429,6 +430,19 @@ CLIENT_CONFIGS: dict[str, ClientConfig] = {
"/v1/traces",
),
),
"dsh": ClientConfig(
cmd="dsh",
label="DeepSeek Harness",
install_url="https://github.com/deepseek-ai/deepseek-harness",
base_url_env="DEEPSEEK_BASE_URL",
base_url_suffix="",
default_target="https://api.deepseek.com",
# A stored dsh model baseURL outranks DEEPSEEK_BASE_URL. Forward mode
# captures both stored and environment-configured endpoints reliably.
default_proxy_mode="forward",
forward_trace_methods=("POST",),
forward_trace_path_suffixes=("/chat/completions",),
),
"codexapp": ClientConfig(
# Prefer the current ChatGPT.app host binary when present; resolution
# still falls back through _codex_app_executable_candidates().
@@ -594,6 +608,27 @@ def _prefer_windows_command_shim(resolved_cmd: str) -> str:
return resolved_cmd
def _node_supports_env_proxy(env: dict[str, str]) -> bool:
"""Return whether the Node runtime on PATH supports ``--use-env-proxy``."""
node_cmd = shutil.which("node", path=env.get("PATH"))
if node_cmd is None:
return False
probe_env = env.copy()
probe_env.pop("NODE_OPTIONS", None)
try:
result = subprocess.run(
[node_cmd, "--use-env-proxy", "--version"],
env=probe_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0
async def run_client(
port: int,
extra_args: list[str],
@@ -646,6 +681,14 @@ async def run_client(
inject_proxy = not cfg.transcript_only
if inject_proxy and proxy_mode == "forward":
if client == "dsh" and not _node_supports_env_proxy(env):
print(
"\nError: DeepSeek Harness forward capture requires a Node runtime "
"with --use-env-proxy support.\n"
"Upgrade Node until `node --use-env-proxy --version` succeeds, or use "
"--tap-proxy-mode reverse when dsh is configured through DEEPSEEK_BASE_URL.\n"
)
return 1
proxy_url = f"http://127.0.0.1:{port}"
if client == "codexapp":
cmd_args.insert(0, f"--proxy-server={proxy_url}")
@@ -659,11 +702,18 @@ async def run_client(
env["http_proxy"] = proxy_url
env["https_proxy"] = proxy_url
env["all_proxy"] = proxy_url
if client == "pi":
# Pi's SSE transport uses Node fetch, which only reads proxy env vars
# when built-in environment proxy support is enabled.
if client in {"dsh", "pi"}:
# These clients use Node fetch, which only reads proxy env vars when
# built-in environment proxy support is enabled.
env["NODE_USE_ENV_PROXY"] = "1"
_extend_no_proxy(env, ("localhost", "127.0.0.1", "::1"))
if client == "dsh":
# A dsh model can store any baseURL, including a loopback gateway.
# Route every child request through tap; the proxy's own upstream
# session still honors the user's original NO_PROXY settings.
env["NO_PROXY"] = ""
env["no_proxy"] = ""
else:
_extend_no_proxy(env, ("localhost", "127.0.0.1", "::1"))
if client == "mimo":
# MiMo defaults to mimo-only mode and ignores provider env vars unless disabled.
env["MIMOCODE_MIMO_ONLY"] = "false"
@@ -1307,6 +1357,14 @@ def _detect_grok_target() -> str:
return CLIENT_CONFIGS["grok"].default_target
def _detect_dsh_target() -> str:
"""Auto-detect the DeepSeek endpoint used by dsh reverse mode."""
env_target = os.environ.get(CLIENT_CONFIGS["dsh"].base_url_env, "").strip()
if env_target:
return env_target
return CLIENT_CONFIGS["dsh"].default_target
def _read_codebuddy_endpoint_cache() -> str | None:
"""Return the host URL from CodeBuddy's login-time endpoint cache, or None."""
config_dir = os.environ.get("CODEBUDDY_CONFIG_DIR", "").strip()
@@ -2232,6 +2290,7 @@ TARGET_DETECTORS = {
"claude": _detect_claude_target,
"codex": _detect_codex_target,
"codebuddy": _detect_codebuddy_target,
"dsh": _detect_dsh_target,
"grok": _detect_grok_target,
"kimi-code": _detect_kimi_code_target,
"openclaw": _detect_openclaw_target,
+12 -1
View File
@@ -122,6 +122,11 @@ def _matches_path_prefix(path: str, prefixes: tuple[str, ...]) -> bool:
)
def _matches_path_suffix(path: str, suffixes: tuple[str, ...]) -> bool:
clean = path.split("?", 1)[0].rstrip("/").lower()
return any(clean.endswith(suffix.lower()) for suffix in suffixes)
def _header_value(headers: Mapping[str, str], name: str) -> str:
if value := headers.get(name):
return value
@@ -273,6 +278,7 @@ class ForwardProxyServer:
local_reverse_allowed_path_prefixes: tuple[str, ...] = (),
trace_methods: tuple[str, ...] = (),
trace_path_prefixes: tuple[str, ...] = (),
trace_path_suffixes: tuple[str, ...] = (),
store_stream_events: bool = False,
capture_only: bool = False,
) -> None:
@@ -285,6 +291,7 @@ class ForwardProxyServer:
self._local_reverse_allowed_path_prefixes = local_reverse_allowed_path_prefixes
self._trace_methods = frozenset(method.upper() for method in trace_methods)
self._trace_path_prefixes = trace_path_prefixes
self._trace_path_suffixes = trace_path_suffixes
self._store_stream_events = store_stream_events
self._capture_only = capture_only
self._server: asyncio.Server | None = None
@@ -296,7 +303,11 @@ class ForwardProxyServer:
def _should_trace_request(self, method: str, path: str) -> bool:
if self._trace_methods and method.upper() not in self._trace_methods:
return False
if self._trace_path_prefixes and not _matches_path_prefix(path, self._trace_path_prefixes):
has_path_filter = bool(self._trace_path_prefixes or self._trace_path_suffixes)
matches_path = _matches_path_prefix(path, self._trace_path_prefixes) or _matches_path_suffix(
path, self._trace_path_suffixes
)
if has_path_filter and not matches_path:
return False
return True
+5 -1
View File
@@ -1,6 +1,6 @@
---
owner: claude-tap-maintainers
last_reviewed: 2026-07-18
last_reviewed: 2026-08-13
source_of_truth: AGENTS.md
---
@@ -26,6 +26,8 @@ Simplified Chinese version: [支持矩阵](support-matrix.zh.md).
| Gemini CLI | Google OAuth / Code Assist | Forward proxy (Google endpoints) | n/a | HTTP/SSE | Real E2E verified |
| Gemini CLI | API key / Vertex-compatible config (`--tap-proxy-mode reverse`) | `https://generativelanguage.googleapis.com` | none | HTTP/SSE | Unit-tested |
| Grok Build CLI | Grok subscription OAuth (`grok login`) | `https://cli-chat-proxy.grok.com/v1` | `/v1` | HTTP/SSE Responses plus storage/trace audit records | Real E2E verified with Grok 0.2.101 |
| DeepSeek Harness (`dsh`) | `DEEPSEEK_API_KEY` or dsh credential store | Configured DeepSeek endpoint, including loopback gateways; default `https://api.deepseek.com` | n/a | Forward proxy HTTP/SSE Chat Completions; requires Node `--use-env-proxy` support | Real E2E verified with dsh 0.0.1-rc.2; local-gateway E2E covered |
| DeepSeek Harness (`dsh`) | Environment-configured endpoint (`--tap-proxy-mode reverse`) | `DEEPSEEK_BASE_URL` or `https://api.deepseek.com` | none | HTTP/SSE Chat Completions | Unit-tested (`DEEPSEEK_BASE_URL`) |
| Kimi CLI (legacy kimi-cli) | Kimi CLI auth/config | `https://api.kimi.com/coding/v1` | none | HTTP/SSE Chat Completions | Unit-tested (`KIMI_BASE_URL`) |
| Kimi CLI (legacy kimi-cli) | Kimi CLI auth/config | `https://api.moonshot.ai/v1` | none | HTTP/SSE Chat Completions | Supported by config |
| Kimi Code CLI | `~/.kimi-code/config.toml` + OAuth (`managed:kimi-code`) | `https://api.kimi.com/coding/v1` | none | HTTP/SSE Chat Completions | Unit-tested (`KIMI_CODE_HOME` sandbox) |
@@ -57,6 +59,7 @@ Each client in `CLIENT_CONFIGS` declares a `default_proxy_mode` used when
| `codexapp` | `forward` | Codex desktop runtime is a macOS `.app` bundle (`ChatGPT.app` today, legacy `Codex.app`; both use `com.openai.codex`) with no `OPENAI_BASE_URL`-style override; forward proxy captures its real upstream HTTP/WebSocket traffic, filtered to `/backend-api/codex/responses` |
| `gemini` | `forward` | Google OAuth / Code Assist uses several Google endpoints; forward proxy captures the flow without assuming a single base URL |
| `grok` | `reverse` | The official CLI honors `GROK_CLI_CHAT_PROXY_BASE_URL`; reverse mode captures model traffic plus storage/trace audit records without installing a local CA |
| `dsh` | `forward` | A stored dsh model `baseURL` outranks `DEEPSEEK_BASE_URL`; verified Node environment-proxy support captures stored, environment-configured, and loopback endpoints while persisting only Chat Completions traffic |
| `kimi` | `reverse` | Legacy kimi-cli; native `KIMI_BASE_URL` env var |
| `kimi-code` | `reverse` | Patches `~/.kimi-code/config.toml` via temporary `KIMI_CODE_HOME` sandbox |
| `mimo` | `forward` | OpenCode fork; multi-provider — forward proxy captures every upstream regardless of which env var the client honors |
@@ -124,6 +127,7 @@ strip = CLIENT_CONFIGS[client].reverse_strip_path_prefix(target)
- `test_forward_proxy_client_filter_*` (`tests/test_e2e.py`) — verifies forward proxy `forward_trace_methods`/`forward_trace_path_prefixes` filtering relays Codex App product traffic while only tracing `/backend-api/codex/responses` HTTP and WebSocket calls
- `test_gemini_registered_in_client_configs` — verifies Gemini CLI registration and default forward mode
- `test_grok_*` — verifies Grok Build registration, reverse-mode URL injection, target detection, `/v1` routing, and fake-upstream Responses/storage/trace capture
- `test_dsh_*` — verifies dsh registration, Node proxy capability checks, loopback/`NO_PROXY` forwarding, Chat Completions-only trace filtering, reverse-mode `DEEPSEEK_BASE_URL` injection, target detection, argument passthrough, and fake-upstream capture
- `test_run_client_gemini_forward_sets_proxy_ca_and_skips_base_url_envs` — verifies Gemini forward proxy launch env
- `test_run_client_gemini_reverse_sets_both_base_url_envs` — verifies Gemini reverse proxy base URL env injection
- `test_viewer_renders_gemini_semantic_sections` — verifies Gemini systemInstruction, contents, functionDeclarations, functionCall, functionResponse, SSE output, and token usage render as semantic viewer sections
+5 -1
View File
@@ -1,6 +1,6 @@
---
owner: claude-tap-maintainers
last_reviewed: 2026-07-18
last_reviewed: 2026-08-13
source_of_truth: AGENTS.md
---
@@ -26,6 +26,8 @@ English version: [Support Matrix](support-matrix.md).
| Gemini CLI | Google OAuth / Code Assist | Forward proxyGoogle 端点) | n/a | HTTP/SSE | 真实 E2E 已验证 |
| Gemini CLI | API key / Vertex 兼容配置(`--tap-proxy-mode reverse` | `https://generativelanguage.googleapis.com` | 无 | HTTP/SSE | 单测覆盖 |
| Grok Build CLI | Grok 订阅 OAuth`grok login` | `https://cli-chat-proxy.grok.com/v1` | `/v1` | HTTP/SSE Responses,以及 storage/trace 审计记录 | 已使用 Grok 0.2.101 完成真实 E2E 验证 |
| DeepSeek Harness`dsh` | `DEEPSEEK_API_KEY` 或 dsh 凭据存储 | 已配置的 DeepSeek 端点,包括本地回环网关;默认 `https://api.deepseek.com` | n/a | Forward proxy HTTP/SSE Chat Completions;要求 Node 支持 `--use-env-proxy` | 已使用 dsh 0.0.1-rc.2 完成真实 E2E 验证;本地网关 E2E 已覆盖 |
| DeepSeek Harness`dsh` | 环境变量配置的端点(`--tap-proxy-mode reverse` | `DEEPSEEK_BASE_URL``https://api.deepseek.com` | 无 | HTTP/SSE Chat Completions | 单测覆盖(`DEEPSEEK_BASE_URL` |
| Kimi CLI(旧版 kimi-cli | Kimi CLI 认证/配置 | `https://api.kimi.com/coding/v1` | 无 | HTTP/SSE Chat Completions | 单测覆盖(`KIMI_BASE_URL` |
| Kimi CLI(旧版 kimi-cli | Kimi CLI 认证/配置 | `https://api.moonshot.ai/v1` | 无 | HTTP/SSE Chat Completions | 配置支持 |
| Kimi Code CLI | `~/.kimi-code/config.toml` + OAuth`managed:kimi-code` | `https://api.kimi.com/coding/v1` | 无 | HTTP/SSE Chat Completions | 单测覆盖(`KIMI_CODE_HOME` sandbox |
@@ -56,6 +58,7 @@ English version: [Support Matrix](support-matrix.md).
| `codexapp` | `forward` | Codex 桌面端是 macOS `.app` bundle(当前多为 `ChatGPT.app`,旧版为 `Codex.app`bundle id 均为 `com.openai.codex`),没有 `OPENAI_BASE_URL` 式覆盖;forward proxy 捕获真实上游 HTTP/WebSocket 流量,并过滤为仅 `/backend-api/codex/responses` |
| `gemini` | `forward` | Google OAuth / Code Assist 会访问多个 Google 端点;forward proxy 不依赖单一 base URL,更适合作为默认 |
| `grok` | `reverse` | 官方 CLI 原生支持 `GROK_CLI_CHAT_PROXY_BASE_URL`;reverse 模式无需安装本地 CA 即可捕获模型流量和 storage/trace 审计记录 |
| `dsh` | `forward` | dsh 模型设置中保存的 `baseURL` 优先于 `DEEPSEEK_BASE_URL`;经能力确认的 Node 环境代理可以捕获设置、环境变量及本地回环端点,并且仅持久化 Chat Completions 流量 |
| `kimi` | `reverse` | 旧版 kimi-cli;原生 `KIMI_BASE_URL` 环境变量 |
| `kimi-code` | `reverse` | 通过临时 `KIMI_CODE_HOME` sandbox 补丁 `~/.kimi-code/config.toml` |
| `mimo` | `forward` | OpenCode fork;多 provider — forward proxy 可以捕获所有上游,而不依赖客户端支持哪个环境变量 |
@@ -118,6 +121,7 @@ strip = CLIENT_CONFIGS[client].reverse_strip_path_prefix(target)
- `test_forward_proxy_client_filter_*``tests/test_e2e.py`):验证 forward proxy 的 `forward_trace_methods`/`forward_trace_path_prefixes` 过滤会照常转发 Codex App 产品流量,但只记录 `/backend-api/codex/responses` 的 HTTP 和 WebSocket 请求
- `test_gemini_registered_in_client_configs`:验证 Gemini CLI 注册和默认 forward 模式
- `test_grok_*`:验证 Grok Build 注册、reverse 模式 URL 注入、目标探测、`/v1` 路由和 fake upstream Responses/storage/trace 捕获
- `test_dsh_*`:验证 dsh 注册、Node 代理能力检查、本地回环/`NO_PROXY` 转发、仅记录 Chat Completions、reverse 模式 `DEEPSEEK_BASE_URL` 注入、目标探测、参数透传和 fake upstream 捕获
- `test_run_client_gemini_forward_sets_proxy_ca_and_skips_base_url_envs`:验证 Gemini forward proxy 启动环境变量
- `test_run_client_gemini_reverse_sets_both_base_url_envs`:验证 Gemini reverse proxy base URL 环境变量注入
- `test_viewer_renders_gemini_semantic_sections`:验证 Gemini systemInstruction、contents、functionDeclarations、functionCall、functionResponse、SSE output 和 token usage 会渲染为语义化 viewer 区块
+2
View File
@@ -14,6 +14,7 @@ SUPPORTED_CLIENTS = {
"claude",
"codex",
"codexapp",
"dsh",
"gemini",
"grok",
"kimi",
@@ -35,6 +36,7 @@ SUPPORTED_DEFAULT_PROXY_MODES = {
"claude": "reverse",
"codex": "reverse",
"codexapp": "forward",
"dsh": "forward",
"gemini": "forward",
"grok": "reverse",
"kimi": "reverse",
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import asyncio
import pytest
from claude_tap import parse_args
from claude_tap.cli import CLIENT_CONFIGS, _reverse_proxy_trace_options, run_client
from claude_tap.cli_clients import _detect_dsh_target, _node_supports_env_proxy
class _DummyProc:
def __init__(self) -> None:
self.pid = 12345
self.returncode: int | None = None
async def wait(self) -> int:
self.returncode = 0
return 0
def terminate(self) -> None:
self.returncode = 0
def kill(self) -> None:
self.returncode = -9
def test_dsh_registered_in_client_configs() -> None:
cfg = CLIENT_CONFIGS["dsh"]
assert cfg.cmd == "dsh"
assert cfg.label == "DeepSeek Harness"
assert cfg.default_target == "https://api.deepseek.com"
assert cfg.base_url_env == "DEEPSEEK_BASE_URL"
assert cfg.base_url_suffix == ""
assert cfg.default_proxy_mode == "forward"
assert cfg.forward_trace_methods == ("POST",)
assert cfg.forward_trace_path_suffixes == ("/chat/completions",)
def test_parse_args_dsh_defaults_to_forward_mode() -> None:
args = parse_args(["--tap-client", "dsh"])
assert args.client == "dsh"
assert args.target == "https://api.deepseek.com"
assert args.proxy_mode == "forward"
@pytest.mark.asyncio
async def test_run_client_dsh_forward_enables_node_proxy_and_preserves_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def fake_create_subprocess_exec(*cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs["env"]
return _DummyProc()
monkeypatch.delenv("DEEPSEEK_BASE_URL", raising=False)
monkeypatch.setenv("NO_PROXY", "localhost,corp.example")
monkeypatch.setenv("no_proxy", "127.0.0.1")
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/dsh")
monkeypatch.setattr("claude_tap.cli_clients._node_supports_env_proxy", lambda _: True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
code = await run_client(
43123,
["--profile", "headless", "Reply OK"],
client="dsh",
proxy_mode="forward",
)
assert code == 0
assert captured["cmd"] == ("/tmp/dsh", "--profile", "headless", "Reply OK")
env = captured["env"]
assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123"
assert env["NODE_USE_ENV_PROXY"] == "1"
assert env["NO_PROXY"] == ""
assert env["no_proxy"] == ""
assert "DEEPSEEK_BASE_URL" not in env
@pytest.mark.asyncio
async def test_run_client_dsh_forward_rejects_node_without_env_proxy_support(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/dsh")
monkeypatch.setattr("claude_tap.cli_clients._node_supports_env_proxy", lambda _: False)
code = await run_client(43123, [], client="dsh", proxy_mode="forward")
assert code == 1
output = capsys.readouterr().out
assert "requires a Node runtime with --use-env-proxy support" in output
assert "--tap-proxy-mode reverse" in output
def test_node_supports_env_proxy_probes_node_on_path(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
def fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs["env"]
return type("Result", (), {"returncode": 0})()
monkeypatch.setattr("claude_tap.cli_clients.shutil.which", lambda cmd, path=None: "/opt/node")
monkeypatch.setattr("claude_tap.cli_clients.subprocess.run", fake_run)
assert _node_supports_env_proxy({"PATH": "/opt/bin", "NODE_OPTIONS": "--inspect"})
assert captured["cmd"] == ["/opt/node", "--use-env-proxy", "--version"]
assert "NODE_OPTIONS" not in captured["env"]
@pytest.mark.asyncio
async def test_run_client_dsh_reverse_sets_base_url_and_preserves_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def fake_create_subprocess_exec(*cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs["env"]
return _DummyProc()
monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/dsh")
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
code = await run_client(
43123,
["--profile", "headless", "Reply OK"],
client="dsh",
proxy_mode="reverse",
)
assert code == 0
assert captured["cmd"] == ("/tmp/dsh", "--profile", "headless", "Reply OK")
assert captured["env"]["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:43123"
def test_detect_dsh_target_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEEPSEEK_BASE_URL", "https://gateway.example.test")
assert _detect_dsh_target() == "https://gateway.example.test"
def test_detect_dsh_target_falls_back_to_public_api(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DEEPSEEK_BASE_URL", raising=False)
assert _detect_dsh_target() == "https://api.deepseek.com"
def test_dsh_reverse_trace_options_do_not_strip_path_prefix() -> None:
options = _reverse_proxy_trace_options("dsh", "https://api.deepseek.com")
assert options == {"strip_path_prefix": "", "force_http": False}
+141
View File
@@ -441,12 +441,17 @@ def _run_claude_tap(
tap_client="claude",
target_suffix="",
no_live=False,
proxy_mode=None,
client_args=None,
client_env=None,
):
"""Run claude_tap as a subprocess pointing at `upstream_port`.
Returns the CompletedProcess."""
env = os.environ.copy()
env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "")
env["PYTHONPATH"] = str(PROJECT_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
if client_env:
env.update(client_env)
env = e2e_env(env, trace_dir)
cmd = [
@@ -462,6 +467,11 @@ def _run_claude_tap(
cmd.extend(["--tap-client", tap_client])
if no_live:
cmd.append("--tap-no-live")
if proxy_mode:
cmd.extend(["--tap-proxy-mode", proxy_mode])
if client_args:
cmd.append("--")
cmd.extend(client_args)
return subprocess.run(
cmd,
@@ -2078,6 +2088,137 @@ def test_grok_client_reverse_proxy():
_cleanup(trace_dir, fake_bin_dir, "grok")
FAKE_DSH_SCRIPT = r"""#!/usr/bin/env python3
# Fake dsh CLI that mirrors a headless DeepSeek Chat Completions request.
import json, os, sys, urllib.request
if sys.argv[1:] != ["--profile", "headless", "Reply with exactly: HELLO_DSH"]:
print(f"[fake-dsh] Unexpected argv: {sys.argv[1:]}", file=sys.stderr)
sys.exit(2)
base = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
telemetry_req = urllib.request.Request(
f"{base}/v1/logs",
data=b"{}",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(telemetry_req) as telemetry_resp:
assert telemetry_resp.status == 204
url = f"{base}/chat/completions"
req_body = json.dumps({
"model": "deepseek-v4-flash",
"messages": [
{"role": "system", "content": "You are a helpful software engineer assistant."},
{"role": "user", "content": "Reply with exactly: HELLO_DSH"},
],
"tools": [{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command.",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
}],
"stream": True,
"stream_options": {"include_usage": True},
}).encode()
req = urllib.request.Request(url, data=req_body, headers={
"Content-Type": "application/json",
"Authorization": "Bearer dsh-test-key-12345678",
})
try:
with urllib.request.urlopen(req) as resp:
chunks = resp.read().decode()
print(f"[fake-dsh] status={resp.status} stream-bytes={len(chunks)}")
except Exception as error:
print(f"[fake-dsh] Error: {error}", file=sys.stderr)
sys.exit(1)
print("HELLO_DSH")
"""
def test_dsh_client_forward_proxy_captures_local_gateway():
"""Test dsh forward mode against a loopback Chat Completions gateway."""
async def handler(request):
from aiohttp import web
if request.path == "/gateway/v1/v1/logs":
return web.Response(status=204)
body = await request.json()
assert request.path == "/gateway/v1/chat/completions"
assert body["model"] == "deepseek-v4-flash"
assert body["tools"][0]["function"]["name"] == "bash"
assert body["stream_options"] == {"include_usage": True}
resp = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"})
await resp.prepare(request)
chunks = [
{
"id": "dsh_chat_1",
"model": body["model"],
"choices": [{"delta": {"role": "assistant", "reasoning_content": "Need exact text."}}],
},
{
"id": "dsh_chat_1",
"model": body["model"],
"choices": [{"delta": {"content": "HELLO_DSH"}}],
},
{
"id": "dsh_chat_1",
"model": body["model"],
"choices": [{"delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 21, "completion_tokens": 3, "total_tokens": 24},
},
]
for chunk in chunks:
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
await resp.write(b"data: [DONE]\n\n")
await resp.write_eof()
return resp
trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_dsh_")
fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_dsh_")
fake_dsh = Path(fake_bin_dir) / "dsh"
fake_dsh.write_text(FAKE_DSH_SCRIPT)
fake_dsh.chmod(fake_dsh.stat().st_mode | stat.S_IEXEC)
stop = _start_fake_upstream(19248, handler)
try:
proc = _run_claude_tap(
Path(__file__).parent,
trace_dir,
fake_bin_dir,
19248,
tap_client="dsh",
proxy_mode="forward",
client_args=["--profile", "headless", "Reply with exactly: HELLO_DSH"],
client_env={"DEEPSEEK_BASE_URL": "http://127.0.0.1:19248/gateway/v1"},
)
assert proc.returncode == 0, f"dsh mode failed: stdout={proc.stdout} stderr={proc.stderr}"
records = read_trace_records(trace_dir)
assert len(records) == 1
record = records[0]
assert record["request"]["path"] == "/gateway/v1/chat/completions"
assert record["upstream_base_url"] == "http://127.0.0.1:19248"
assert record["request"]["body"]["model"] == "deepseek-v4-flash"
assert record["request"]["body"]["tools"][0]["function"]["name"] == "bash"
assert record["response"]["body"]["content"][0]["type"] == "thinking"
assert record["response"]["body"]["content"][1]["text"] == "HELLO_DSH"
assert record["response"]["body"]["usage"]["input_tokens"] == 21
assert "forward proxy" in proc.stdout
finally:
stop()
_cleanup(trace_dir, fake_bin_dir, "dsh")
FAKE_KIMI_SCRIPT = r"""#!/usr/bin/env python3
# Fake Kimi CLI that sends one streaming Chat Completions request via KIMI_BASE_URL
import json, os, sys, urllib.request