refactor(config): simplify to file-only config with new cli flags and recover default (#15)

main
Yixing Lao 2026-04-26 17:30:10 +08:00 committed by GitHub
parent 4988f0f86c
commit 9b8c1f76b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 234 additions and 362 deletions

View File

@ -2,10 +2,12 @@
A compatibility proxy that connects Cursor to DeepSeek thinking models (`deepseek-v4-pro` and `deepseek-v4-flash`) by properly handling the `reasoning_content` field for DeepSeek tool-call reasoning API requests.
This proxy can also help _other applications and coding agents_ beyond Cursor that run into the same missing `reasoning_content` issue with DeepSeek's thinking-mode API. Just point their API base URL at the proxy.
## What It Does
- ✅ Injects `reasoning_content` into outgoing tool-call requests since Cursor does not include the field, restoring previously cached reasoning from regular and streamed DeepSeek responses. See [DeepSeek docs](https://api-docs.deepseek.com/guides/thinking_mode#tool-calls) for more details.
- ✅ Mirrors streamed `reasoning_content` into Cursor-visible `<think>...</think>` text so that thinking tokens are shown in Cursor's UI. For BYOK/proxy mode, Cursor renders this as normal text, not as a native collapsible thinking block.
- ✅ Mirrors streamed `reasoning_content` into Cursor-visible `<think>...</think>` text so that thinking tokens are shown in Cursor UI. For BYOK (bring your own key) mode, Cursor renders this as normal text, not as a native collapsible thinking block.
- ✅ Starts an ngrok tunnel so Cursor can reach the local proxy through a public HTTPS URL.
- ✅ Provides other compatibility fixes to make DeepSeek models run well in Cursor.
@ -13,7 +15,7 @@ A compatibility proxy that connects Cursor to DeepSeek thinking models (`deepsee
This repository fixes the following Cursor + DeepSeek tool-call error with thinking mode enabled:
![Error 400 - reasoning_content must be passed back](assets/error_400.png)
<img src="assets/error_400.png" width="600" alt="Error 400 - reasoning_content must be passed back">
```txt
⚠️ Connection Error
@ -34,9 +36,11 @@ Provider returned error:
Cursor blocks non-public API URLs such as `localhost`, so the proxy needs a public HTTPS URL. [ngrok](https://ngrok.com/) can expose the local proxy to Cursor without opening router ports. Alternatively, you may use [Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/).
If you're using this proxy with another application that allows localhost API endpoints, you can skip this step entirely by setting `ngrok: false` in `~/.deepseek-cursor-proxy/config.yaml`, or by starting the proxy with `--no-ngrok`.
Create an ngrok account, then visit ngrok's dashboard: https://dashboard.ngrok.com
![ngrok dashboard](assets/ngrok_dashboard.png)
<img src="assets/ngrok_dashboard.png" width="600" alt="ngrok dashboard">
Then, install and authenticate ngrok once:
@ -61,7 +65,7 @@ For example, if ngrok dashboard shows `https://example.ngrok-free.app`, use:
https://example.ngrok-free.app/v1
```
![Cursor settings for DeepSeek through the proxy](assets/cursor_config.png)
<img src="assets/cursor_config.png" width="600" alt="Cursor settings for DeepSeek through the proxy">
Note: you can toggle the custom API on and off with:
@ -100,27 +104,44 @@ pip install -e .
deepseek-cursor-proxy
```
On start, `deepseek-cursor-proxy` will print the ngrok public URL. If it differs from the one in Cursor, update it in Cursor's Base URL field.
When ngrok is enabled, `deepseek-cursor-proxy` will print the ngrok public URL on start. If it differs from the one in Cursor, update it in Cursor's Base URL field.
On the first run, `deepseek-cursor-proxy` will create:
- `~/.deepseek-cursor-proxy/config.yaml`: the configuration file
- `~/.deepseek-cursor-proxy/reasoning_content.sqlite3`: the reasoning content cache
Persistent settings live in `~/.deepseek-cursor-proxy/config.yaml`. Command-line flags override the config for a single run, for example `--no-ngrok`, `--port 9000`, or `--verbose`.
### Step 4: Chat with DeepSeek in Cursor
Select `deepseek-v4-pro` in Cursor and use chat or agent mode as usual.
![Chatting with DeepSeek in Cursor](assets/cursor_chat.png)
<img src="assets/cursor_chat.png" width="480" alt="Chatting with DeepSeek in Cursor">
## How It Works
DeepSeek's [thinking mode](https://api-docs.deepseek.com/guides/thinking_mode#tool-calls) requires `reasoning_content` from assistant messages in tool-call sequences to be passed back in later requests. Cursor may omit this field, causing DeepSeek to return a 400 error. This proxy sits between Cursor and DeepSeek (`Cursor → ngrok → proxy → DeepSeek API`) and repairs requests when it has the exact original reasoning cached.
- Core fix: every DeepSeek response, streaming or non-streaming, has its `reasoning_content` stored in a local SQLite cache keyed by message signature, tool-call ID, and tool-call function signature. On outgoing thinking-mode requests, the proxy restores missing `reasoning_content` for tool-call-related assistant messages and sends the complete history to DeepSeek. If the cache is cold, such as after a proxy restart or model switch, the default recovery mode omits older unrecoverable tool-call history, continues from the latest user request, logs the recovery, and prefixes the next Cursor response with a small notice.
- Multi-conversation isolation: cache keys are scoped by a SHA-256 hash of the canonical conversation prefix (roles, content, tool calls, excluding `reasoning_content`) plus the upstream model/configuration and an API-key hash. Concurrent or interleaved threads with different histories get different scopes, so reused tool-call IDs do not collide. Byte-identical cloned histories are indistinguishable unless Cursor sends a differentiating history.
- DeepSeek [prefix caching](https://api-docs.deepseek.com/guides/kv_cache) compatibility: the proxy does not inject synthetic thread IDs, timestamps, or cache-control messages into the prompt. When it restores cached reasoning, it restores the exact original string, preserving repeated prefixes for DeepSeek's automatic best-effort context cache.
- Additional compatibility fixes: the proxy converts legacy `functions`/`function_call` fields to `tools`/`tool_choice`, preserves required and named tool-choice semantics, normalizes `reasoning_effort` aliases per DeepSeek docs, strips mirrored `<think>` blocks from assistant content, converts multi-part content arrays to plain text, logs DeepSeek prompt-cache usage when available, and mirrors `reasoning_content` into Cursor-visible `<think>...</think>` blocks for thinking display.
- **Core fix:** every DeepSeek response, streaming or non-streaming, has its `reasoning_content` stored in a local SQLite cache keyed by message signature, tool-call ID, and tool-call function signature. On outgoing thinking-mode requests, the proxy restores missing `reasoning_content` for tool-call-related assistant messages and sends the complete history to DeepSeek. If the cache is cold, such as after a proxy restart or model switch, the default recovery mode omits older unrecoverable tool-call history, continues from the latest user request, logs the recovery, and prefixes the next Cursor response with a small notice.
- **Multi-conversation isolation:** cache keys are scoped by a SHA-256 hash of the canonical conversation prefix (roles, content, tool calls, excluding `reasoning_content`) plus the upstream model/configuration and an API-key hash. Concurrent or interleaved threads with different histories get different scopes, so reused tool-call IDs do not collide. Byte-identical cloned histories are indistinguishable unless Cursor sends a differentiating history.
- **DeepSeek [context caching](https://api-docs.deepseek.com/guides/kv_cache) compatibility:** the proxy does not inject synthetic thread IDs, timestamps, or cache-control messages into the prompt. When it restores cached reasoning, it restores the exact original string, preserving repeated prefixes for DeepSeek's automatic best-effort context cache.
- **Additional compatibility fixes:** the proxy converts legacy `functions`/`function_call` fields to `tools`/`tool_choice`, preserves required and named tool-choice semantics, normalizes `reasoning_effort` aliases per DeepSeek docs, strips mirrored `<think>` blocks from assistant content, converts multi-part content arrays to plain text, logs DeepSeek prompt-cache usage when available, and mirrors `reasoning_content` into Cursor-visible `<think>...</think>` blocks for thinking display.
## Development
Run unit tests:
```bash
uv run python -m unittest discover -s tests
```
Run pre-commit hooks (code formatting and linting):
```bash
uv sync --dev
uv run pre-commit run --all-files
```
## Debugging
@ -135,7 +156,7 @@ deepseek-cursor-proxy --verbose
Run without ngrok for local curl testing:
```bash
PROXY_NGROK=false deepseek-cursor-proxy --port 9000 --verbose
deepseek-cursor-proxy --no-ngrok --port 9000 --verbose
```
Use another config file:
@ -149,9 +170,3 @@ Clear the local reasoning cache:
```bash
deepseek-cursor-proxy --clear-reasoning-cache
```
Run tests:
```bash
PYTHONPATH=src python -m unittest discover -s tests
```

View File

@ -18,6 +18,6 @@ max_request_body_bytes: 20971520
cors: false
reasoning_content_path: reasoning_content.sqlite3
missing_reasoning_strategy: reject
missing_reasoning_strategy: recover
reasoning_cache_max_age_seconds: 604800
reasoning_cache_max_rows: 10000

View File

@ -4,7 +4,6 @@ from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import os
import yaml
@ -38,6 +37,7 @@ max_request_body_bytes: 20971520
cors: false
reasoning_content_path: reasoning_content.sqlite3
missing_reasoning_strategy: recover
reasoning_cache_max_age_seconds: 604800
reasoning_cache_max_rows: 10000
"""
@ -78,61 +78,11 @@ def load_config_file(config_path: str | Path) -> dict[str, Any]:
return dict(loaded)
def migrate_default_config_file(
settings: dict[str, Any],
config_path: Path,
live_env: Mapping[str, str],
original_config_path: str | Path | None,
) -> dict[str, Any]:
if original_config_path is not None:
return settings
if "DEEPSEEK_CURSOR_PROXY_CONFIG_PATH" in live_env:
return settings
if config_path != default_config_path():
return settings
if "missing_reasoning_strategy" not in settings:
return settings
migrated = dict(settings)
migrated.pop("missing_reasoning_strategy", None)
try:
text = config_path.read_text(encoding="utf-8")
except OSError:
return migrated
if not text.startswith(DEFAULT_CONFIG_HEADER):
return settings
updated_lines = [
line
for line in text.splitlines()
if not line.strip().startswith("missing_reasoning_strategy:")
]
updated_text = "\n".join(updated_lines) + "\n"
if updated_text != text:
config_path.write_text(updated_text, encoding="utf-8")
config_path.chmod(0o600)
return migrated
def resolve_config_path(config_path: str | Path | None) -> Path:
return Path(config_path or default_config_path()).expanduser()
def resolve_config_path(
env: Mapping[str, str] | None, config_path: str | Path | None
) -> Path:
live_env = os.environ if env is None else env
return Path(
config_path
or live_env.get("DEEPSEEK_CURSOR_PROXY_CONFIG_PATH")
or default_config_path()
).expanduser()
def setting_value(
settings: Mapping[str, Any],
env: Mapping[str, str],
key: str,
env_name: str,
) -> Any:
if env_name in env:
return env[env_name]
def setting_value(settings: Mapping[str, Any], key: str) -> Any:
return settings.get(key, MISSING)
@ -184,26 +134,29 @@ def as_path(value: Any, default_path: Path, relative_base: Path) -> Path:
return relative_base / candidate_path
def settings_and_env(
env: Mapping[str, str] | None, config_path: str | Path | None
) -> tuple[dict[str, Any], dict[str, str], Path]:
live_env = dict(os.environ if env is None else env)
original_config_path = config_path
config_path = resolve_config_path(live_env, config_path)
if (
config_path == default_config_path()
and "DEEPSEEK_CURSOR_PROXY_CONFIG_PATH" not in live_env
and not config_path.exists()
):
populate_default_config_file(config_path)
settings = load_config_file(config_path)
settings = migrate_default_config_file(
settings,
config_path,
live_env,
original_config_path,
)
return settings, live_env, config_path
def settings_from_config(
config_path: str | Path | None,
) -> tuple[dict[str, Any], Path]:
resolved_config_path = resolve_config_path(config_path)
if config_path is None and not resolved_config_path.exists():
populate_default_config_file(resolved_config_path)
return load_config_file(resolved_config_path), resolved_config_path
def normalize_thinking(value: Any) -> str:
thinking = as_str(value, "enabled").strip().lower()
if thinking in {"passthrough", "pass-through", "pass_through"}:
return "pass-through"
if thinking in {"enabled", "disabled"}:
return thinking
return "enabled"
def normalize_missing_reasoning_strategy(value: Any) -> str:
strategy = as_str(value, "recover").strip().lower()
if strategy in {"recover", "reject"}:
return strategy
return "recover"
@dataclass(frozen=True)
@ -212,7 +165,6 @@ class ProxyConfig:
port: int = 9000
upstream_base_url: str = "https://api.deepseek.com"
upstream_model: str = "deepseek-v4-pro"
allow_model_passthrough: bool = False
thinking: str = "enabled"
reasoning_effort: str = "high"
request_timeout: float = 300.0
@ -229,166 +181,71 @@ class ProxyConfig:
@classmethod
def from_file(
cls: type[ProxyConfig],
env: Mapping[str, str] | None = None,
config_path: str | Path | None = None,
) -> "ProxyConfig":
settings, live_env, resolved_config_path = settings_and_env(env, config_path)
settings, resolved_config_path = settings_from_config(config_path)
config_dir = resolved_config_path.parent
thinking = (
as_str(
setting_value(
settings,
live_env,
"thinking",
"DEEPSEEK_THINKING",
),
"enabled",
)
.strip()
.lower()
)
if thinking in {"passthrough", "pass-through", "pass_through"}:
thinking = "pass-through"
if thinking not in {"enabled", "disabled", "pass-through"}:
thinking = "enabled"
return cls(
host=as_str(
setting_value(
settings,
live_env,
"host",
"PROXY_HOST",
),
setting_value(settings, "host"),
"127.0.0.1",
),
port=as_int(
setting_value(
settings,
live_env,
"port",
"PROXY_PORT",
),
setting_value(settings, "port"),
9000,
),
upstream_base_url=as_str(
setting_value(
settings,
live_env,
"base_url",
"DEEPSEEK_BASE_URL",
),
setting_value(settings, "base_url"),
"https://api.deepseek.com",
).rstrip("/"),
upstream_model=as_str(
setting_value(
settings,
live_env,
"model",
"DEEPSEEK_MODEL",
),
setting_value(settings, "model"),
"deepseek-v4-pro",
),
allow_model_passthrough=as_bool(
setting_value(
settings,
live_env,
"allow_model_passthrough",
"DEEPSEEK_ALLOW_MODEL_PASSTHROUGH",
),
False,
),
thinking=thinking,
thinking=normalize_thinking(setting_value(settings, "thinking")),
reasoning_effort=as_str(
setting_value(
settings,
live_env,
"reasoning_effort",
"DEEPSEEK_REASONING_EFFORT",
),
setting_value(settings, "reasoning_effort"),
"high",
),
request_timeout=as_float(
setting_value(
settings,
live_env,
"request_timeout",
"PROXY_REQUEST_TIMEOUT",
),
setting_value(settings, "request_timeout"),
300.0,
),
max_request_body_bytes=as_int(
setting_value(
settings,
live_env,
"max_request_body_bytes",
"PROXY_MAX_REQUEST_BODY_BYTES",
),
setting_value(settings, "max_request_body_bytes"),
20 * 1024 * 1024,
),
reasoning_content_path=as_path(
setting_value(
settings,
live_env,
"reasoning_content_path",
"REASONING_CONTENT_PATH",
),
setting_value(settings, "reasoning_content_path"),
default_reasoning_content_path(),
config_dir,
),
missing_reasoning_strategy=normalize_missing_reasoning_strategy(
setting_value(settings, "missing_reasoning_strategy")
),
reasoning_cache_max_age_seconds=as_int(
setting_value(
settings,
live_env,
"reasoning_cache_max_age_seconds",
"REASONING_CACHE_MAX_AGE_SECONDS",
),
setting_value(settings, "reasoning_cache_max_age_seconds"),
7 * 24 * 60 * 60,
),
reasoning_cache_max_rows=as_int(
setting_value(
settings,
live_env,
"reasoning_cache_max_rows",
"REASONING_CACHE_MAX_ROWS",
),
setting_value(settings, "reasoning_cache_max_rows"),
10000,
),
cursor_display_reasoning=as_bool(
setting_value(
settings,
live_env,
"display_reasoning",
"CURSOR_DISPLAY_REASONING",
),
setting_value(settings, "display_reasoning"),
True,
),
cors=as_bool(
setting_value(
settings,
live_env,
"cors",
"PROXY_CORS",
),
setting_value(settings, "cors"),
False,
),
verbose=as_bool(
setting_value(
settings,
live_env,
"verbose",
"PROXY_VERBOSE",
),
setting_value(settings, "verbose"),
False,
),
ngrok=as_bool(
setting_value(
settings,
live_env,
"ngrok",
"PROXY_NGROK",
),
setting_value(settings, "ngrok"),
False,
),
)

View File

@ -606,21 +606,32 @@ def build_arg_parser() -> argparse.ArgumentParser:
type=Path,
help=f"YAML config file, default {default_config_path()}",
)
parser.add_argument(
"--host", help="Bind host, default from config, PROXY_HOST, or 127.0.0.1"
)
parser.add_argument("--host", help="Bind host, default from config or 127.0.0.1")
parser.add_argument(
"--port",
type=int,
help="Bind port, default from config, PROXY_PORT, or 9000",
help="Bind port, default from config or 9000",
)
parser.add_argument(
"--model",
help="Fallback DeepSeek model when the request has no model, default from config, DEEPSEEK_MODEL, or deepseek-v4-pro",
help=(
"Fallback DeepSeek model when the request has no model, "
"default from config or deepseek-v4-pro"
),
)
parser.add_argument(
"--base-url",
help="DeepSeek base URL, default from config, DEEPSEEK_BASE_URL, or https://api.deepseek.com",
help=("DeepSeek base URL, default from config or https://api.deepseek.com"),
)
parser.add_argument(
"--thinking",
choices=["enabled", "disabled", "pass-through"],
help="DeepSeek thinking mode, default from config or enabled",
)
parser.add_argument(
"--reasoning-effort",
choices=["low", "medium", "high", "max", "xhigh"],
help="DeepSeek reasoning effort, default from config or high",
)
parser.add_argument(
"--reasoning-content-path",
@ -632,18 +643,47 @@ def build_arg_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--ngrok",
action="store_true",
action=argparse.BooleanOptionalAction,
default=None,
help="Start an ngrok tunnel and print the Cursor base URL",
)
parser.add_argument(
"--verbose",
action="store_true",
action=argparse.BooleanOptionalAction,
default=None,
help="Log detailed request lifecycle metadata and full payloads",
)
parser.add_argument(
"--no-cursor-display-reasoning",
action="store_true",
help="Do not mirror reasoning_content into Cursor-visible <think> content",
"--display-reasoning",
action=argparse.BooleanOptionalAction,
default=None,
help="Mirror reasoning_content into Cursor-visible <think> content",
)
parser.add_argument(
"--cors",
action=argparse.BooleanOptionalAction,
default=None,
help="Send permissive CORS headers",
)
parser.add_argument(
"--request-timeout",
type=float,
help="Upstream request timeout in seconds, default from config or 300",
)
parser.add_argument(
"--max-request-body-bytes",
type=int,
help="Maximum accepted request body size, default from config",
)
parser.add_argument(
"--reasoning-cache-max-age-seconds",
type=int,
help="Maximum reasoning cache row age in seconds, default from config",
)
parser.add_argument(
"--reasoning-cache-max-rows",
type=int,
help="Maximum reasoning cache rows, default from config",
)
parser.add_argument(
"--missing-reasoning-strategy",
@ -872,23 +912,39 @@ def main(argv: list[str] | None = None) -> int:
LOG.error("%s", exc)
return 2
updates: dict[str, Any] = {}
if args.host:
if args.host is not None:
updates["host"] = args.host
if args.port:
if args.port is not None:
updates["port"] = args.port
if args.model:
if args.model is not None:
updates["upstream_model"] = args.model
if args.base_url:
if args.base_url is not None:
updates["upstream_base_url"] = args.base_url.rstrip("/")
if args.reasoning_content_path:
if args.thinking is not None:
updates["thinking"] = args.thinking
if args.reasoning_effort is not None:
updates["reasoning_effort"] = args.reasoning_effort
if args.reasoning_content_path is not None:
updates["reasoning_content_path"] = args.reasoning_content_path
if args.ngrok:
updates["ngrok"] = True
if args.verbose:
updates["verbose"] = True
if args.no_cursor_display_reasoning:
updates["cursor_display_reasoning"] = False
if args.missing_reasoning_strategy:
if args.ngrok is not None:
updates["ngrok"] = args.ngrok
if args.verbose is not None:
updates["verbose"] = args.verbose
if args.display_reasoning is not None:
updates["cursor_display_reasoning"] = args.display_reasoning
if args.cors is not None:
updates["cors"] = args.cors
if args.request_timeout is not None:
updates["request_timeout"] = args.request_timeout
if args.max_request_body_bytes is not None:
updates["max_request_body_bytes"] = args.max_request_body_bytes
if args.reasoning_cache_max_age_seconds is not None:
updates["reasoning_cache_max_age_seconds"] = (
args.reasoning_cache_max_age_seconds
)
if args.reasoning_cache_max_rows is not None:
updates["reasoning_cache_max_rows"] = args.reasoning_cache_max_rows
if args.missing_reasoning_strategy is not None:
updates["missing_reasoning_strategy"] = args.missing_reasoning_strategy
if updates:
config = replace(config, **updates)

View File

@ -8,7 +8,6 @@ import unittest
from unittest.mock import patch
from deepseek_cursor_proxy.config import (
DEFAULT_CONFIG_TEXT,
ProxyConfig,
default_config_path,
default_reasoning_content_path,
@ -37,48 +36,26 @@ class ConfigTests(unittest.TestCase):
home = Path(temp_dir)
with patch("deepseek_cursor_proxy.config.Path.home", return_value=home):
config = ProxyConfig.from_file(env={}, config_path=None)
config = ProxyConfig.from_file(config_path=None)
config_path = default_config_path()
self.assertTrue(config_path.exists())
self.assertIn(
"model: deepseek-v4-pro", config_path.read_text(encoding="utf-8")
)
self.assertNotIn(
"missing_reasoning_strategy",
self.assertIn(
"missing_reasoning_strategy: recover",
config_path.read_text(encoding="utf-8"),
)
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
self.assertEqual(config.upstream_model, "deepseek-v4-pro")
self.assertEqual(config.missing_reasoning_strategy, "recover")
def test_legacy_generated_default_config_removes_missing_reasoning_key(
self,
) -> None:
with TemporaryDirectory() as temp_dir:
home = Path(temp_dir)
with patch("deepseek_cursor_proxy.config.Path.home", return_value=home):
config_path = default_config_path()
config_path.parent.mkdir(parents=True)
config_path.write_text(
DEFAULT_CONFIG_TEXT + "\nmissing_reasoning_strategy: reject\n",
encoding="utf-8",
)
config = ProxyConfig.from_file(env={}, config_path=None)
self.assertEqual(config.missing_reasoning_strategy, "recover")
self.assertNotIn(
"missing_reasoning_strategy",
config_path.read_text(encoding="utf-8"),
)
def test_missing_explicit_config_file_is_not_populated(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "missing.yaml"
config = ProxyConfig.from_file(env={}, config_path=config_path)
config = ProxyConfig.from_file(config_path=config_path)
self.assertFalse(config_path.exists())
self.assertEqual(config.upstream_model, "deepseek-v4-pro")
@ -90,52 +67,67 @@ class ConfigTests(unittest.TestCase):
config_path.write_text(
"\n".join(
[
"base_url: https://example.com/v1/",
"model: deepseek-v4-flash",
"thinking: pass_through",
"reasoning_effort: max",
"port: 9100",
"host: 0.0.0.0",
"ngrok: true",
"verbose: true",
"request_timeout: 123.5",
"max_request_body_bytes: 1234",
"cors: true",
"display_reasoning: false",
f"reasoning_content_path: {reasoning_content_path}",
"missing_reasoning_strategy: reject",
"reasoning_cache_max_age_seconds: 60",
"reasoning_cache_max_rows: 50",
]
),
encoding="utf-8",
)
config = ProxyConfig.from_file(env={}, config_path=config_path)
config = ProxyConfig.from_file(config_path=config_path)
self.assertEqual(config.upstream_base_url, "https://example.com/v1")
self.assertEqual(config.upstream_model, "deepseek-v4-flash")
self.assertEqual(config.thinking, "pass-through")
self.assertEqual(config.reasoning_effort, "max")
self.assertEqual(config.host, "0.0.0.0")
self.assertEqual(config.port, 9100)
self.assertTrue(config.ngrok)
self.assertTrue(config.verbose)
self.assertEqual(config.request_timeout, 123.5)
self.assertEqual(config.max_request_body_bytes, 1234)
self.assertTrue(config.cors)
self.assertFalse(config.cursor_display_reasoning)
self.assertEqual(config.reasoning_content_path, reasoning_content_path)
self.assertEqual(config.missing_reasoning_strategy, "reject")
self.assertEqual(config.reasoning_cache_max_age_seconds, 60)
self.assertEqual(config.reasoning_cache_max_rows, 50)
def test_missing_reasoning_strategy_config_key_is_ignored(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
config_path.write_text(
"missing_reasoning_strategy: reject\n",
encoding="utf-8",
)
config = ProxyConfig.from_file(env={}, config_path=config_path)
self.assertEqual(config.missing_reasoning_strategy, "recover")
def test_environment_overrides_config_file(self) -> None:
def test_invalid_config_values_fall_back_to_defaults(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
config_path.write_text(
"\n".join(
[
"verbose: false",
"thinking: maybe",
"missing_reasoning_strategy: maybe",
"port: nope",
"verbose: maybe",
]
),
encoding="utf-8",
)
config = ProxyConfig.from_file(
env={
"PROXY_VERBOSE": "true",
},
config_path=config_path,
)
config = ProxyConfig.from_file(config_path=config_path)
self.assertTrue(config.verbose)
self.assertEqual(config.thinking, "enabled")
self.assertEqual(config.missing_reasoning_strategy, "recover")
self.assertEqual(config.port, 9000)
self.assertFalse(config.verbose)
def test_relative_reasoning_content_path_in_config_is_relative_to_config_file(
self,
@ -151,59 +143,12 @@ class ConfigTests(unittest.TestCase):
encoding="utf-8",
)
config = ProxyConfig.from_file(env={}, config_path=config_path)
config = ProxyConfig.from_file(config_path=config_path)
self.assertEqual(
config.reasoning_content_path, Path(temp_dir) / "custom.sqlite3"
)
def test_relative_reasoning_content_path_from_env_stays_inside_app_directory(
self,
) -> None:
home = Path("/tmp/home")
with patch("deepseek_cursor_proxy.config.Path.home", return_value=home):
config = ProxyConfig.from_file(
env={
"REASONING_CONTENT_PATH": "custom.sqlite3",
},
config_path=None,
)
self.assertEqual(
config.reasoning_content_path,
home / ".deepseek-cursor-proxy" / "custom.sqlite3",
)
def test_verbose_logging_can_be_enabled_from_env(self) -> None:
config = ProxyConfig.from_file(
env={
"PROXY_VERBOSE": "true",
"PROXY_NGROK": "yes",
"PROXY_CORS": "true",
"PROXY_MAX_REQUEST_BODY_BYTES": "1234",
"REASONING_CACHE_MAX_AGE_SECONDS": "60",
"REASONING_CACHE_MAX_ROWS": "50",
},
config_path=Path("/does/not/exist"),
)
self.assertTrue(config.verbose)
self.assertTrue(config.ngrok)
self.assertTrue(config.cors)
self.assertEqual(config.max_request_body_bytes, 1234)
self.assertEqual(config.missing_reasoning_strategy, "recover")
self.assertEqual(config.reasoning_cache_max_age_seconds, 60)
self.assertEqual(config.reasoning_cache_max_rows, 50)
def test_missing_reasoning_strategy_environment_is_ignored(self) -> None:
config = ProxyConfig.from_file(
env={"MISSING_REASONING_STRATEGY": "reject"},
config_path=Path("/does/not/exist"),
)
self.assertEqual(config.missing_reasoning_strategy, "recover")
def test_cursor_reasoning_display_can_be_disabled_from_config(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
@ -216,58 +161,41 @@ class ConfigTests(unittest.TestCase):
encoding="utf-8",
)
config = ProxyConfig.from_file(env={}, config_path=config_path)
config = ProxyConfig.from_file(config_path=config_path)
self.assertFalse(config.cursor_display_reasoning)
def test_config_path_can_be_overridden_from_environment(self) -> None:
with TemporaryDirectory() as temp_dir:
first_config_path = Path(temp_dir) / "first.yaml"
second_config_path = Path(temp_dir) / "second.yaml"
first_config_path.write_text("port: 9100\n", encoding="utf-8")
second_config_path.write_text("port: 9200\n", encoding="utf-8")
config = ProxyConfig.from_file(
env={"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": str(second_config_path)},
config_path=None,
)
self.assertEqual(config.port, 9200)
def test_explicit_config_file_path_wins_over_config_path_environment_variable(
self,
) -> None:
with TemporaryDirectory() as temp_dir:
first_config_path = Path(temp_dir) / "first.yaml"
second_config_path = Path(temp_dir) / "second.yaml"
first_config_path.write_text("port: 9100\n", encoding="utf-8")
second_config_path.write_text("port: 9200\n", encoding="utf-8")
config = ProxyConfig.from_file(
env={"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": str(second_config_path)},
config_path=first_config_path,
)
self.assertEqual(config.port, 9100)
def test_invalid_yaml_config_raises_value_error(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
config_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
with self.assertRaises(ValueError):
ProxyConfig.from_file(env={}, config_path=config_path)
ProxyConfig.from_file(config_path=config_path)
def test_process_environment_does_not_override_config(self) -> None:
with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml"
config_path.write_text("verbose: false\n", encoding="utf-8")
def test_from_file_does_not_mutate_process_environment(self) -> None:
with patch.dict(
"os.environ",
{
"PROXY_VERBOSE": "true",
"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": "/ignored.yaml",
},
clear=True,
):
ProxyConfig.from_file(config_path=Path("/does/not/exist"))
self.assertEqual(dict(os.environ), {"PROXY_VERBOSE": "true"})
config = ProxyConfig.from_file(config_path=config_path)
self.assertEqual(
dict(os.environ),
{
"PROXY_VERBOSE": "true",
"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": "/ignored.yaml",
},
)
self.assertFalse(config.verbose)
if __name__ == "__main__":

View File

@ -11,6 +11,7 @@ from deepseek_cursor_proxy.config import ProxyConfig
from deepseek_cursor_proxy.reasoning_store import ReasoningStore
from deepseek_cursor_proxy.server import (
DeepSeekProxyHandler,
build_arg_parser,
read_response_body,
summarize_chat_payload,
)
@ -72,6 +73,21 @@ def make_proxy_handler(wfile: object) -> DeepSeekProxyHandler:
class ServerTests(unittest.TestCase):
def test_cli_boolean_overrides_have_on_and_off_forms(self) -> None:
args = build_arg_parser().parse_args(
[
"--no-ngrok",
"--no-verbose",
"--no-display-reasoning",
"--cors",
]
)
self.assertFalse(args.ngrok)
self.assertFalse(args.verbose)
self.assertFalse(args.display_reasoning)
self.assertTrue(args.cors)
def test_read_response_body_handles_gzip(self) -> None:
body = gzip.compress(b'{"ok":true}')