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. 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 ## 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. - ✅ 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. - ✅ 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. - ✅ 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: 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 ```txt
⚠️ Connection Error ⚠️ 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/). 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 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: 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 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: Note: you can toggle the custom API on and off with:
@ -100,27 +104,44 @@ pip install -e .
deepseek-cursor-proxy 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: On the first run, `deepseek-cursor-proxy` will create:
- `~/.deepseek-cursor-proxy/config.yaml`: the configuration file - `~/.deepseek-cursor-proxy/config.yaml`: the configuration file
- `~/.deepseek-cursor-proxy/reasoning_content.sqlite3`: the reasoning content cache - `~/.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 ### Step 4: Chat with DeepSeek in Cursor
Select `deepseek-v4-pro` in Cursor and use chat or agent mode as usual. 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 ## 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. 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. - **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. - **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. - **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. - **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 ## Debugging
@ -135,7 +156,7 @@ deepseek-cursor-proxy --verbose
Run without ngrok for local curl testing: Run without ngrok for local curl testing:
```bash ```bash
PROXY_NGROK=false deepseek-cursor-proxy --port 9000 --verbose deepseek-cursor-proxy --no-ngrok --port 9000 --verbose
``` ```
Use another config file: Use another config file:
@ -149,9 +170,3 @@ Clear the local reasoning cache:
```bash ```bash
deepseek-cursor-proxy --clear-reasoning-cache 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 cors: false
reasoning_content_path: reasoning_content.sqlite3 reasoning_content_path: reasoning_content.sqlite3
missing_reasoning_strategy: reject missing_reasoning_strategy: recover
reasoning_cache_max_age_seconds: 604800 reasoning_cache_max_age_seconds: 604800
reasoning_cache_max_rows: 10000 reasoning_cache_max_rows: 10000

View File

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

View File

@ -606,21 +606,32 @@ def build_arg_parser() -> argparse.ArgumentParser:
type=Path, type=Path,
help=f"YAML config file, default {default_config_path()}", help=f"YAML config file, default {default_config_path()}",
) )
parser.add_argument( parser.add_argument("--host", help="Bind host, default from config or 127.0.0.1")
"--host", help="Bind host, default from config, PROXY_HOST, or 127.0.0.1"
)
parser.add_argument( parser.add_argument(
"--port", "--port",
type=int, type=int,
help="Bind port, default from config, PROXY_PORT, or 9000", help="Bind port, default from config or 9000",
) )
parser.add_argument( parser.add_argument(
"--model", "--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( parser.add_argument(
"--base-url", "--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( parser.add_argument(
"--reasoning-content-path", "--reasoning-content-path",
@ -632,18 +643,47 @@ def build_arg_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
"--ngrok", "--ngrok",
action="store_true", action=argparse.BooleanOptionalAction,
default=None,
help="Start an ngrok tunnel and print the Cursor base URL", help="Start an ngrok tunnel and print the Cursor base URL",
) )
parser.add_argument( parser.add_argument(
"--verbose", "--verbose",
action="store_true", action=argparse.BooleanOptionalAction,
default=None,
help="Log detailed request lifecycle metadata and full payloads", help="Log detailed request lifecycle metadata and full payloads",
) )
parser.add_argument( parser.add_argument(
"--no-cursor-display-reasoning", "--display-reasoning",
action="store_true", action=argparse.BooleanOptionalAction,
help="Do not mirror reasoning_content into Cursor-visible <think> content", 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( parser.add_argument(
"--missing-reasoning-strategy", "--missing-reasoning-strategy",
@ -872,23 +912,39 @@ def main(argv: list[str] | None = None) -> int:
LOG.error("%s", exc) LOG.error("%s", exc)
return 2 return 2
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
if args.host: if args.host is not None:
updates["host"] = args.host updates["host"] = args.host
if args.port: if args.port is not None:
updates["port"] = args.port updates["port"] = args.port
if args.model: if args.model is not None:
updates["upstream_model"] = args.model updates["upstream_model"] = args.model
if args.base_url: if args.base_url is not None:
updates["upstream_base_url"] = args.base_url.rstrip("/") 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 updates["reasoning_content_path"] = args.reasoning_content_path
if args.ngrok: if args.ngrok is not None:
updates["ngrok"] = True updates["ngrok"] = args.ngrok
if args.verbose: if args.verbose is not None:
updates["verbose"] = True updates["verbose"] = args.verbose
if args.no_cursor_display_reasoning: if args.display_reasoning is not None:
updates["cursor_display_reasoning"] = False updates["cursor_display_reasoning"] = args.display_reasoning
if args.missing_reasoning_strategy: 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 updates["missing_reasoning_strategy"] = args.missing_reasoning_strategy
if updates: if updates:
config = replace(config, **updates) config = replace(config, **updates)

View File

@ -8,7 +8,6 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from deepseek_cursor_proxy.config import ( from deepseek_cursor_proxy.config import (
DEFAULT_CONFIG_TEXT,
ProxyConfig, ProxyConfig,
default_config_path, default_config_path,
default_reasoning_content_path, default_reasoning_content_path,
@ -37,48 +36,26 @@ class ConfigTests(unittest.TestCase):
home = Path(temp_dir) home = Path(temp_dir)
with patch("deepseek_cursor_proxy.config.Path.home", return_value=home): 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() config_path = default_config_path()
self.assertTrue(config_path.exists()) self.assertTrue(config_path.exists())
self.assertIn( self.assertIn(
"model: deepseek-v4-pro", config_path.read_text(encoding="utf-8") "model: deepseek-v4-pro", config_path.read_text(encoding="utf-8")
) )
self.assertNotIn( self.assertIn(
"missing_reasoning_strategy", "missing_reasoning_strategy: recover",
config_path.read_text(encoding="utf-8"), config_path.read_text(encoding="utf-8"),
) )
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600) self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
self.assertEqual(config.upstream_model, "deepseek-v4-pro") self.assertEqual(config.upstream_model, "deepseek-v4-pro")
self.assertEqual(config.missing_reasoning_strategy, "recover") 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: def test_missing_explicit_config_file_is_not_populated(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "missing.yaml" 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.assertFalse(config_path.exists())
self.assertEqual(config.upstream_model, "deepseek-v4-pro") self.assertEqual(config.upstream_model, "deepseek-v4-pro")
@ -90,52 +67,67 @@ class ConfigTests(unittest.TestCase):
config_path.write_text( config_path.write_text(
"\n".join( "\n".join(
[ [
"base_url: https://example.com/v1/",
"model: deepseek-v4-flash", "model: deepseek-v4-flash",
"thinking: pass_through",
"reasoning_effort: max",
"port: 9100", "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}", 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", 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.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.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.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: 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(
"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:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml" config_path = Path(temp_dir) / "config.yaml"
config_path.write_text( config_path.write_text(
"\n".join( "\n".join(
[ [
"verbose: false", "thinking: maybe",
"missing_reasoning_strategy: maybe",
"port: nope",
"verbose: maybe",
] ]
), ),
encoding="utf-8", encoding="utf-8",
) )
config = ProxyConfig.from_file( config = ProxyConfig.from_file(config_path=config_path)
env={
"PROXY_VERBOSE": "true",
},
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( def test_relative_reasoning_content_path_in_config_is_relative_to_config_file(
self, self,
@ -151,59 +143,12 @@ class ConfigTests(unittest.TestCase):
encoding="utf-8", encoding="utf-8",
) )
config = ProxyConfig.from_file(env={}, config_path=config_path) config = ProxyConfig.from_file(config_path=config_path)
self.assertEqual( self.assertEqual(
config.reasoning_content_path, Path(temp_dir) / "custom.sqlite3" 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: def test_cursor_reasoning_display_can_be_disabled_from_config(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml" config_path = Path(temp_dir) / "config.yaml"
@ -216,58 +161,41 @@ class ConfigTests(unittest.TestCase):
encoding="utf-8", 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) 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: def test_invalid_yaml_config_raises_value_error(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
config_path = Path(temp_dir) / "config.yaml" config_path = Path(temp_dir) / "config.yaml"
config_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") config_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
with self.assertRaises(ValueError): 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( with patch.dict(
"os.environ", "os.environ",
{ {
"PROXY_VERBOSE": "true", "PROXY_VERBOSE": "true",
"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": "/ignored.yaml",
}, },
clear=True, clear=True,
): ):
ProxyConfig.from_file(config_path=Path("/does/not/exist")) config = ProxyConfig.from_file(config_path=config_path)
self.assertEqual(dict(os.environ), {"PROXY_VERBOSE": "true"}) self.assertEqual(
dict(os.environ),
{
"PROXY_VERBOSE": "true",
"DEEPSEEK_CURSOR_PROXY_CONFIG_PATH": "/ignored.yaml",
},
)
self.assertFalse(config.verbose)
if __name__ == "__main__": 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.reasoning_store import ReasoningStore
from deepseek_cursor_proxy.server import ( from deepseek_cursor_proxy.server import (
DeepSeekProxyHandler, DeepSeekProxyHandler,
build_arg_parser,
read_response_body, read_response_body,
summarize_chat_payload, summarize_chat_payload,
) )
@ -72,6 +73,21 @@ def make_proxy_handler(wfile: object) -> DeepSeekProxyHandler:
class ServerTests(unittest.TestCase): 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: def test_read_response_body_handles_gzip(self) -> None:
body = gzip.compress(b'{"ok":true}') body = gzip.compress(b'{"ok":true}')