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

312 lines
8.8 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 os
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_TEXT = """# This file was created automatically at ~/.deepseek-cursor-proxy/config.yaml.
# API keys are read from Cursor's Authorization header and forwarded upstream.
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
log_bodies: false
request_timeout: 300
reasoning_content_path: reasoning_content.sqlite3
"""
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(
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)
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_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)
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)
return load_config_file(config_path), live_env, config_path
@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"
allow_model_passthrough: bool = False
thinking: str = "enabled"
reasoning_effort: str = "high"
request_timeout: float = 300.0
reasoning_content_path: Path = field(default_factory=default_reasoning_content_path)
cursor_display_reasoning: bool = True
verbose: bool = False
log_bodies: bool = False
ngrok: bool = False
@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)
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",
),
"127.0.0.1",
),
port=as_int(
setting_value(
settings,
live_env,
"port",
"PROXY_PORT",
),
9000,
),
upstream_base_url=as_str(
setting_value(
settings,
live_env,
"base_url",
"DEEPSEEK_BASE_URL",
),
"https://api.deepseek.com",
).rstrip("/"),
upstream_model=as_str(
setting_value(
settings,
live_env,
"model",
"DEEPSEEK_MODEL",
),
"deepseek-v4-pro",
),
allow_model_passthrough=as_bool(
setting_value(
settings,
live_env,
"allow_model_passthrough",
"DEEPSEEK_ALLOW_MODEL_PASSTHROUGH",
),
False,
),
thinking=thinking,
reasoning_effort=as_str(
setting_value(
settings,
live_env,
"reasoning_effort",
"DEEPSEEK_REASONING_EFFORT",
),
"high",
),
request_timeout=as_float(
setting_value(
settings,
live_env,
"request_timeout",
"PROXY_REQUEST_TIMEOUT",
),
300.0,
),
reasoning_content_path=as_path(
setting_value(
settings,
live_env,
"reasoning_content_path",
"REASONING_CONTENT_PATH",
),
default_reasoning_content_path(),
config_dir,
),
cursor_display_reasoning=as_bool(
setting_value(
settings,
live_env,
"display_reasoning",
"CURSOR_DISPLAY_REASONING",
),
True,
),
verbose=as_bool(
setting_value(
settings,
live_env,
"verbose",
"PROXY_VERBOSE",
),
False,
),
log_bodies=as_bool(
setting_value(
settings,
live_env,
"log_bodies",
"PROXY_LOG_BODIES",
),
False,
),
ngrok=as_bool(
setting_value(
settings,
live_env,
"ngrok",
"PROXY_NGROK",
),
False,
),
)