deepseek-cursor-proxy/src/deepseek_cursor_proxy/config.py

252 lines
7.7 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
APP_DIR_NAME = ".deepseek-cursor-proxy"
CONFIG_FILE_NAME = "config.yaml"
REASONING_CONTENT_FILE_NAME = "reasoning_content.sqlite3"
TRUE_VALUES = {"1", "true", "yes", "on"}
FALSE_VALUES = {"0", "false", "no", "off"}
MISSING = object()
DEFAULT_CONFIG_HEADER = (
"# This file was created automatically at ~/.deepseek-cursor-proxy/config.yaml."
)
DEFAULT_CONFIG_TEXT = f"""{DEFAULT_CONFIG_HEADER}
# API keys are read from Cursor's Authorization header and forwarded upstream.
# `model` is the fallback when a request has no model; Cursor's requested
# DeepSeek model name is otherwise respected.
base_url: https://api.deepseek.com
model: deepseek-v4-pro
thinking: enabled
reasoning_effort: high
display_reasoning: true
host: 127.0.0.1
port: 9000
ngrok: true
verbose: false
request_timeout: 300
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
"""
def default_app_dir() -> Path:
return Path.home() / APP_DIR_NAME
def default_config_path() -> Path:
return default_app_dir() / CONFIG_FILE_NAME
def default_reasoning_content_path() -> Path:
return default_app_dir() / REASONING_CONTENT_FILE_NAME
def populate_default_config_file(config_path: Path) -> None:
config_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
config_path.parent.chmod(0o700)
config_path.write_text(DEFAULT_CONFIG_TEXT, encoding="utf-8")
config_path.chmod(0o600)
def load_config_file(config_path: str | Path) -> dict[str, Any]:
config_path = Path(config_path).expanduser()
if not config_path.exists():
return {}
try:
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise ValueError(f"Invalid YAML config at {config_path}: {exc}") from exc
if loaded is None:
return {}
if not isinstance(loaded, Mapping):
raise ValueError(f"Config file must contain a YAML mapping: {config_path}")
return dict(loaded)
def resolve_config_path(config_path: str | Path | None) -> Path:
return Path(config_path or default_config_path()).expanduser()
def setting_value(settings: Mapping[str, Any], key: str) -> Any:
return settings.get(key, MISSING)
def as_str(value: Any, default: str) -> str:
if value is MISSING or value is None:
return default
return str(value)
def as_bool(value: Any, default: bool) -> bool:
if value is MISSING or value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int):
return bool(value)
normalized = str(value).strip().lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return default
def as_int(value: Any, default: int) -> int:
if value is MISSING or value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def as_float(value: Any, default: float) -> float:
if value is MISSING or value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def as_path(value: Any, default_path: Path, relative_base: Path) -> Path:
if value is MISSING or value is None or value == "":
return default_path
candidate_path = Path(str(value)).expanduser()
if candidate_path.is_absolute():
return candidate_path
return relative_base / candidate_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)
class ProxyConfig:
host: str = "127.0.0.1"
port: int = 9000
upstream_base_url: str = "https://api.deepseek.com"
upstream_model: str = "deepseek-v4-pro"
thinking: str = "enabled"
reasoning_effort: str = "high"
request_timeout: float = 300.0
max_request_body_bytes: int = 20 * 1024 * 1024
reasoning_content_path: Path = field(default_factory=default_reasoning_content_path)
missing_reasoning_strategy: str = "recover"
reasoning_cache_max_age_seconds: int = 7 * 24 * 60 * 60
reasoning_cache_max_rows: int = 10000
cursor_display_reasoning: bool = True
cors: bool = False
verbose: bool = False
ngrok: bool = False
@classmethod
def from_file(
cls: type[ProxyConfig],
config_path: str | Path | None = None,
) -> "ProxyConfig":
settings, resolved_config_path = settings_from_config(config_path)
config_dir = resolved_config_path.parent
return cls(
host=as_str(
setting_value(settings, "host"),
"127.0.0.1",
),
port=as_int(
setting_value(settings, "port"),
9000,
),
upstream_base_url=as_str(
setting_value(settings, "base_url"),
"https://api.deepseek.com",
).rstrip("/"),
upstream_model=as_str(
setting_value(settings, "model"),
"deepseek-v4-pro",
),
thinking=normalize_thinking(setting_value(settings, "thinking")),
reasoning_effort=as_str(
setting_value(settings, "reasoning_effort"),
"high",
),
request_timeout=as_float(
setting_value(settings, "request_timeout"),
300.0,
),
max_request_body_bytes=as_int(
setting_value(settings, "max_request_body_bytes"),
20 * 1024 * 1024,
),
reasoning_content_path=as_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, "reasoning_cache_max_age_seconds"),
7 * 24 * 60 * 60,
),
reasoning_cache_max_rows=as_int(
setting_value(settings, "reasoning_cache_max_rows"),
10000,
),
cursor_display_reasoning=as_bool(
setting_value(settings, "display_reasoning"),
True,
),
cors=as_bool(
setting_value(settings, "cors"),
False,
),
verbose=as_bool(
setting_value(settings, "verbose"),
False,
),
ngrok=as_bool(
setting_value(settings, "ngrok"),
False,
),
)