diff --git a/README.md b/README.md
index 9eaef18..ede44bb 100644
--- a/README.md
+++ b/README.md
@@ -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 `...` 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 `...` 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:
-
+
```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
-
+
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
```
-
+
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.
-
+
## 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 `` 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 `...` 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 `` 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 `...` 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
-```
diff --git a/config.example.yaml b/config.example.yaml
index fa19a81..5f357fc 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -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
diff --git a/src/deepseek_cursor_proxy/config.py b/src/deepseek_cursor_proxy/config.py
index 8d7e3fb..ba6590b 100644
--- a/src/deepseek_cursor_proxy/config.py
+++ b/src/deepseek_cursor_proxy/config.py
@@ -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,
),
)
diff --git a/src/deepseek_cursor_proxy/server.py b/src/deepseek_cursor_proxy/server.py
index 04d5e91..6914bf6 100644
--- a/src/deepseek_cursor_proxy/server.py
+++ b/src/deepseek_cursor_proxy/server.py
@@ -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 content",
+ "--display-reasoning",
+ action=argparse.BooleanOptionalAction,
+ default=None,
+ help="Mirror reasoning_content into Cursor-visible 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)
diff --git a/tests/test_config.py b/tests/test_config.py
index facd9a2..6d84807 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -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__":
diff --git a/tests/test_server.py b/tests/test_server.py
index 04ced54..25240f6 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -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}')