refactor(config): simplify to file-only config with new cli flags and recover default (#15)
parent
4988f0f86c
commit
9b8c1f76b7
49
README.md
49
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.
|
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:
|
||||||
|
|
||||||

|
<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
|
||||||
|
|
||||||

|
<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
|
||||||
```
|
```
|
||||||
|
|
||||||

|
<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.
|
||||||
|
|
||||||

|
<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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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}')
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue