From 9b8c1f76b7dc767015957372f6107b1792bbf3df Mon Sep 17 00:00:00 2001 From: Yixing Lao Date: Sun, 26 Apr 2026 17:30:10 +0800 Subject: [PATCH] refactor(config): simplify to file-only config with new cli flags and recover default (#15) --- README.md | 49 ++++-- config.example.yaml | 2 +- src/deepseek_cursor_proxy/config.py | 235 ++++++---------------------- src/deepseek_cursor_proxy/server.py | 102 +++++++++--- tests/test_config.py | 192 +++++++---------------- tests/test_server.py | 16 ++ 6 files changed, 234 insertions(+), 362 deletions(-) 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: -![Error 400 - reasoning_content must be passed back](assets/error_400.png) +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) +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) +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) +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 `` 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}')