refactor(logging): simplified prints and add spinner (#37)

main
Yixing Lao 2026-05-04 19:35:58 +08:00 committed by GitHub
parent 3ed8da6307
commit a35583a3e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 308 additions and 103 deletions

View File

@ -0,0 +1,96 @@
from __future__ import annotations
import logging as stdlib_logging
import sys
import threading
from typing import Any
LOG = stdlib_logging.getLogger("deepseek_cursor_proxy")
DEFAULT_INFO_LOG_FORMAT = "%(message)s"
DEFAULT_WARNING_LOG_FORMAT = "%(levelname)s %(message)s"
VERBOSE_LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s"
class ConsoleLogFormatter(stdlib_logging.Formatter):
def __init__(self, *, verbose: bool) -> None:
super().__init__()
self.verbose = verbose
self._verbose_formatter = stdlib_logging.Formatter(VERBOSE_LOG_FORMAT)
self._info_formatter = stdlib_logging.Formatter(DEFAULT_INFO_LOG_FORMAT)
self._warning_formatter = stdlib_logging.Formatter(DEFAULT_WARNING_LOG_FORMAT)
def format(self, record: stdlib_logging.LogRecord) -> str:
if self.verbose:
return self._verbose_formatter.format(record)
if record.levelno <= stdlib_logging.INFO:
return self._info_formatter.format(record)
return self._warning_formatter.format(record)
def configure_logging(*, verbose: bool) -> None:
handler = stdlib_logging.StreamHandler()
handler.setFormatter(ConsoleLogFormatter(verbose=verbose))
stdlib_logging.basicConfig(
level=stdlib_logging.INFO,
handlers=[handler],
force=True,
)
class TerminalSpinner:
hide_cursor = "\x1b[?25l"
show_cursor = "\x1b[?25h"
frames = ("", "", "", "", "", "", "", "", "", "")
def __init__(
self,
*,
enabled: bool,
text: str,
stream: Any | None = None,
interval: float = 0.12,
) -> None:
self.stream = stream if stream is not None else sys.stderr
self.enabled = enabled and bool(getattr(self.stream, "isatty", lambda: False)())
self.text = text
self.interval = interval
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._visible = False
def start(self) -> "TerminalSpinner":
if not self.enabled or self._thread is not None:
return self
self.stream.write(self.hide_cursor)
self.stream.flush()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def stop(self) -> None:
if not self.enabled:
return
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=1)
self._thread = None
if self._visible:
self.stream.write("\r" + (" " * self._clear_width()) + "\r")
self.stream.flush()
self._visible = False
self.stream.write(self.show_cursor)
self.stream.flush()
def _run(self) -> None:
index = 0
while not self._stop.is_set():
self.stream.write("\r" + self.text.format(frame=self.frames[index]))
self.stream.flush()
self._visible = True
index = (index + 1) % len(self.frames)
self._stop.wait(self.interval)
def _clear_width(self) -> int:
return max(len(self.text.format(frame=frame)) for frame in self.frames)

View File

@ -5,7 +5,6 @@ from dataclasses import dataclass, replace
import gzip import gzip
from http.client import HTTPException from http.client import HTTPException
import json import json
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
import sys import sys
@ -21,21 +20,22 @@ from .config import (
default_config_path, default_config_path,
default_reasoning_content_path, default_reasoning_content_path,
) )
from .logging import (
LOG,
TerminalSpinner,
configure_logging,
)
from .reasoning_store import ReasoningStore, conversation_scope from .reasoning_store import ReasoningStore, conversation_scope
from .streaming import CursorReasoningDisplayAdapter, StreamAccumulator from .streaming import CursorReasoningDisplayAdapter, StreamAccumulator
from .trace import TraceRequest, TraceWriter from .trace import TraceRequest, TraceWriter
from .tunnel import NgrokTunnel, local_tunnel_target from .tunnel import NgrokTunnel, local_tunnel_target
from .transform import ( from .transform import (
PreparedRequest,
RECOVERY_NOTICE_CONTENT, RECOVERY_NOTICE_CONTENT,
prepare_upstream_request, prepare_upstream_request,
rewrite_response_body, rewrite_response_body,
) )
LOG = logging.getLogger("deepseek_cursor_proxy")
class RequestBodyTooLarge(ValueError): class RequestBodyTooLarge(ValueError):
pass pass
@ -237,13 +237,19 @@ class DeepSeekProxyHandler(BaseHTTPRequestHandler):
headers=upstream_headers, headers=upstream_headers,
) )
log_send_summary(prepared) if self.config.verbose:
log_send_summary(prepared)
spinner = TerminalSpinner(
enabled=bool(prepared.payload.get("stream")) and not self.config.verbose,
text="{frame}",
).start()
try: try:
if self.config.verbose: if self.config.verbose:
LOG.info("forwarding to %s", upstream_url) LOG.info("forwarding to %s", upstream_url)
response = urlopen(request, timeout=self.config.request_timeout) response = urlopen(request, timeout=self.config.request_timeout)
except HTTPError as exc: except HTTPError as exc:
spinner.stop()
LOG.warning( LOG.warning(
"request failed upstream_status=%s stream=%s elapsed_ms=%s", "request failed upstream_status=%s stream=%s elapsed_ms=%s",
exc.code, exc.code,
@ -259,6 +265,7 @@ class DeepSeekProxyHandler(BaseHTTPRequestHandler):
) )
return return
except URLError as exc: except URLError as exc:
spinner.stop()
LOG.warning( LOG.warning(
"upstream request failed elapsed_ms=%s reason=%s", "upstream request failed elapsed_ms=%s reason=%s",
elapsed_ms(started), elapsed_ms(started),
@ -271,55 +278,63 @@ class DeepSeekProxyHandler(BaseHTTPRequestHandler):
) )
self._finish_trace(trace, "upstream_error", http_status=502) self._finish_trace(trace, "upstream_error", http_status=502)
return return
except Exception:
spinner.stop()
raise
with response: try:
upstream_status = getattr(response, "status", 200) with response:
if self.config.verbose: upstream_status = getattr(response, "status", 200)
LOG.info( if self.config.verbose:
"upstream response status=%s stream=%s elapsed_ms=%s", LOG.info(
upstream_status, "upstream response status=%s stream=%s elapsed_ms=%s",
bool(prepared.payload.get("stream")), upstream_status,
elapsed_ms(started), bool(prepared.payload.get("stream")),
) elapsed_ms(started),
if prepared.payload.get("stream"): )
sent_response = self._proxy_streaming_response( if prepared.payload.get("stream"):
response, sent_response = self._proxy_streaming_response(
prepared.original_model, response,
prepared.payload["messages"], prepared.original_model,
prepared.cache_namespace, prepared.payload["messages"],
prepared.recovery_notice, prepared.cache_namespace,
trace=trace, prepared.recovery_notice,
record_response_scope=prepared.record_response_scope, trace=trace,
record_response_messages=prepared.record_response_messages, record_response_scope=prepared.record_response_scope,
record_response_contexts=prepared.record_response_contexts, record_response_messages=prepared.record_response_messages,
) record_response_contexts=prepared.record_response_contexts,
else: )
sent_response = self._proxy_regular_response( else:
response, sent_response = self._proxy_regular_response(
prepared.original_model, response,
prepared.payload["messages"], prepared.original_model,
prepared.cache_namespace, prepared.payload["messages"],
prepared.recovery_notice, prepared.cache_namespace,
trace=trace, prepared.recovery_notice,
record_response_scope=prepared.record_response_scope, trace=trace,
record_response_messages=prepared.record_response_messages, record_response_scope=prepared.record_response_scope,
record_response_contexts=prepared.record_response_contexts, record_response_messages=prepared.record_response_messages,
) record_response_contexts=prepared.record_response_contexts,
if not sent_response.sent: )
if not sent_response.sent:
spinner.stop()
self._finish_trace(
trace,
"client_disconnected",
http_status=upstream_status,
stream=bool(prepared.payload.get("stream")),
)
return
spinner.stop()
log_stats_summary(sent_response.usage)
self._finish_trace( self._finish_trace(
trace, trace,
"client_disconnected", "completed",
http_status=upstream_status, http_status=upstream_status,
stream=bool(prepared.payload.get("stream")), stream=bool(prepared.payload.get("stream")),
) )
return finally:
log_stats_summary(sent_response.usage) spinner.stop()
self._finish_trace(
trace,
"completed",
http_status=upstream_status,
stream=bool(prepared.payload.get("stream")),
)
def _start_trace(self, request_path: str) -> TraceRequest | None: def _start_trace(self, request_path: str) -> TraceRequest | None:
writer = self.trace_writer writer = self.trace_writer
@ -997,26 +1012,31 @@ def log_cursor_request(
) -> None: ) -> None:
model = str(payload.get("model") or config.upstream_model) model = str(payload.get("model") or config.upstream_model)
LOG.info( LOG.info(
"cursor model=%s effort=%s messages=%s tools=%s", "request model=%s effort=%s messages=%s",
model, model,
config.reasoning_effort, config.reasoning_effort,
format_count(message_count(payload)), format_count(message_count(payload)),
format_count(tool_count(payload)),
) )
def log_context_summary(prepared: PreparedRequest) -> None: def log_context_summary(prepared: Any) -> None:
status = context_status(prepared)
if status == "ok":
LOG.info(
"├ context status=ok reasoning_context=%s",
format_count(prepared.patched_reasoning_messages),
)
return
LOG.info( LOG.info(
"├ context filled=%s missing=%s recovered=%s dropped=%s status=%s", "├ context status=%s missing=%s recovered=%s dropped=%s",
format_count(prepared.patched_reasoning_messages), status,
format_count(prepared.missing_reasoning_messages), format_count(prepared.missing_reasoning_messages),
format_count(prepared.recovered_reasoning_messages), format_count(prepared.recovered_reasoning_messages),
format_count(prepared.recovery_dropped_messages), format_count(prepared.recovery_dropped_messages),
context_status(prepared),
) )
def log_send_summary(prepared: PreparedRequest) -> None: def log_send_summary(prepared: Any) -> None:
LOG.info( LOG.info(
"├ send user_msgs=%s messages=%s tools=%s reasoning_content=%s", "├ send user_msgs=%s messages=%s tools=%s reasoning_content=%s",
format_count(user_message_count(prepared.payload)), format_count(user_message_count(prepared.payload)),
@ -1036,7 +1056,7 @@ def log_stats_summary(usage: dict[str, Any] | None) -> None:
) )
def context_status(prepared: PreparedRequest) -> str: def context_status(prepared: Any) -> str:
if prepared.recovered_reasoning_messages: if prepared.recovered_reasoning_messages:
return "recovered" return "recovered"
if prepared.missing_reasoning_messages: if prepared.missing_reasoning_messages:
@ -1216,13 +1236,11 @@ def warn_if_insecure_upstream(url: str) -> None:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
args = build_arg_parser().parse_args(argv) args = build_arg_parser().parse_args(argv)
try: try:
config = ProxyConfig.from_file(config_path=args.config_path) config = ProxyConfig.from_file(config_path=args.config_path)
except ValueError as exc: except ValueError as exc:
configure_logging(verbose=bool(args.verbose))
LOG.error("%s", exc) LOG.error("%s", exc)
return 2 return 2
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
@ -1267,6 +1285,7 @@ def main(argv: list[str] | None = None) -> int:
if updates: if updates:
config = replace(config, **updates) config = replace(config, **updates)
configure_logging(verbose=config.verbose)
warn_if_insecure_upstream(config.upstream_base_url) warn_if_insecure_upstream(config.upstream_base_url)
store = ReasoningStore( store = ReasoningStore(
config.reasoning_content_path, config.reasoning_content_path,
@ -1291,37 +1310,8 @@ def main(argv: list[str] | None = None) -> int:
server.reasoning_store = store server.reasoning_store = store
server.trace_writer = trace_writer server.trace_writer = trace_writer
LOG.info("listening on http://%s:%s/v1", config.host, config.port)
LOG.info(
"forwarding to %s/chat/completions default_model=%s",
config.upstream_base_url,
config.upstream_model,
)
LOG.info(
(
"thinking=%s reasoning_effort=%s display_reasoning=%s "
"collapsible_reasoning=%s missing_reasoning_strategy=%s "
"reasoning_content_path=%s"
),
config.thinking,
config.reasoning_effort,
config.display_reasoning,
config.collapsible_reasoning,
config.missing_reasoning_strategy,
config.reasoning_content_path,
)
if config.verbose:
LOG.info("logging mode=verbose metadata=detailed bodies=true")
LOG.warning(
"verbose logging enabled; prompts and code may be written to stdout"
)
else:
LOG.info("logging mode=normal metadata=safe_summaries bodies=false")
if trace_writer is not None:
LOG.info("trace session directory: %s", trace_writer.session_dir)
LOG.warning("trace logging enabled; prompts and code will be written to disk")
tunnel: NgrokTunnel | None = None tunnel: NgrokTunnel | None = None
public_url: str | None = None
if config.ngrok: if config.ngrok:
target_url = local_tunnel_target(config.host, config.port) target_url = local_tunnel_target(config.host, config.port)
tunnel = NgrokTunnel(target_url) tunnel = NgrokTunnel(target_url)
@ -1332,8 +1322,39 @@ def main(argv: list[str] | None = None) -> int:
server.server_close() server.server_close()
store.close() store.close()
return 2 return 2
LOG.info("ngrok tunnel forwarding %s -> %s", public_url, target_url) local_base_url = f"http://{config.host}:{config.port}/v1"
LOG.info("api base url: %s/v1", public_url.rstrip("/")) api_base_url = (
f"{public_url.rstrip('/')}/v1" if public_url is not None else local_base_url
)
LOG.info(
"default_model: %s (%s, %s)",
config.upstream_model,
"thinking" if config.thinking == "enabled" else "no thinking",
config.reasoning_effort,
)
if config.verbose:
display_reasoning = "off"
if config.display_reasoning:
display_reasoning = (
"on (collapsible)" if config.collapsible_reasoning else "on"
)
LOG.info("display_reasoning: %s", display_reasoning)
LOG.info("missing_reasoning_strategy: %s", config.missing_reasoning_strategy)
LOG.info("reasoning_cache: %s", config.reasoning_content_path)
LOG.warning(
"verbose logging enabled; prompts and code may be written to stdout"
)
if trace_writer is not None:
LOG.info("trace_dir: %s", trace_writer.session_dir)
LOG.warning("trace logging enabled; prompts and code will be written to disk")
if public_url is None and not config.ngrok:
LOG.info("public_tunnel: off")
if config.verbose:
LOG.info("upstream_url: %s/chat/completions", config.upstream_base_url)
LOG.info("local_base_url: %s", local_base_url)
LOG.info("api_base_url: %s", api_base_url)
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -3,11 +3,11 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import hashlib import hashlib
import json import json
import logging
import re import re
from typing import Any from typing import Any
from .config import ProxyConfig from .config import ProxyConfig
from .logging import LOG
from .reasoning_store import ( from .reasoning_store import (
ReasoningStore, ReasoningStore,
conversation_scope, conversation_scope,
@ -20,9 +20,6 @@ from .reasoning_store import (
from .streaming import fold_reasoning_into_content from .streaming import fold_reasoning_into_content
LOG = logging.getLogger("deepseek_cursor_proxy")
SUPPORTED_REQUEST_FIELDS = { SUPPORTED_REQUEST_FIELDS = {
"model", "model",
"messages", "messages",

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import logging
import shutil import shutil
import subprocess import subprocess
import time import time
@ -10,8 +9,7 @@ from typing import Any
from urllib.error import URLError from urllib.error import URLError
from urllib.request import urlopen from urllib.request import urlopen
from .logging import LOG
LOG = logging.getLogger("deepseek_cursor_proxy")
DEFAULT_NGROK_API_URL = "http://127.0.0.1:4040/api" DEFAULT_NGROK_API_URL = "http://127.0.0.1:4040/api"

View File

@ -14,7 +14,9 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO from io import BytesIO
import gzip import gzip
import json import json
import logging
from pathlib import Path from pathlib import Path
import re
import threading import threading
import time import time
from types import SimpleNamespace from types import SimpleNamespace
@ -24,6 +26,10 @@ from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from deepseek_cursor_proxy.config import ProxyConfig from deepseek_cursor_proxy.config import ProxyConfig
from deepseek_cursor_proxy.logging import (
ConsoleLogFormatter,
TerminalSpinner,
)
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,
@ -80,6 +86,21 @@ class _BrokenPipeWfile:
raise BrokenPipeError("test disconnect") raise BrokenPipeError("test disconnect")
class _FakeConsole:
def __init__(self, *, tty: bool) -> None:
self.tty = tty
self.writes: list[str] = []
def isatty(self) -> bool:
return self.tty
def write(self, text: str) -> None:
self.writes.append(text)
def flush(self) -> None:
return
def _make_handler_stub(wfile: object, **config: object) -> DeepSeekProxyHandler: def _make_handler_stub(wfile: object, **config: object) -> DeepSeekProxyHandler:
handler = object.__new__(DeepSeekProxyHandler) handler = object.__new__(DeepSeekProxyHandler)
handler.server = SimpleNamespace( handler.server = SimpleNamespace(
@ -119,6 +140,76 @@ class CliAndHelperTests(unittest.TestCase):
self.assertTrue(args.cors) self.assertTrue(args.cors)
self.assertEqual(args.trace_dir, Path("/tmp/dcp-traces")) self.assertEqual(args.trace_dir, Path("/tmp/dcp-traces"))
def test_default_console_logging_hides_info_prefix_and_timestamp(self) -> None:
formatter = ConsoleLogFormatter(verbose=False)
info_record = logging.LogRecord(
"deepseek_cursor_proxy",
logging.INFO,
__file__,
1,
"listening on %s",
("http://127.0.0.1:9000/v1",),
None,
)
warning_record = logging.LogRecord(
"deepseek_cursor_proxy",
logging.WARNING,
__file__,
1,
"trace logging enabled",
(),
None,
)
self.assertEqual(
formatter.format(info_record),
"listening on http://127.0.0.1:9000/v1",
)
self.assertEqual(
formatter.format(warning_record), "WARNING trace logging enabled"
)
def test_verbose_console_logging_shows_timestamp_and_level(self) -> None:
formatter = ConsoleLogFormatter(verbose=True)
record = logging.LogRecord(
"deepseek_cursor_proxy",
logging.INFO,
__file__,
1,
"listening on %s",
("http://127.0.0.1:9000/v1",),
None,
)
self.assertRegex(
formatter.format(record),
re.compile(
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} INFO listening on "
),
)
def test_terminal_spinner_animates_only_for_tty(self) -> None:
tty = _FakeConsole(tty=True)
spinner = TerminalSpinner(
enabled=True, text="{frame}", stream=tty, interval=0.001
).start()
deadline = time.monotonic() + 0.2
while time.monotonic() < deadline and not tty.writes:
time.sleep(0.001)
spinner.stop()
output = "".join(tty.writes)
self.assertIn(TerminalSpinner.hide_cursor, output)
self.assertIn("└ ⠋", output)
self.assertIn(TerminalSpinner.show_cursor, output)
self.assertTrue(output.endswith(TerminalSpinner.show_cursor))
non_tty = _FakeConsole(tty=False)
TerminalSpinner(
enabled=True, text="{frame}", stream=non_tty, interval=0.001
).start().stop()
self.assertEqual(non_tty.writes, [])
def test_read_response_body_decodes_gzip_and_deflate(self) -> None: def test_read_response_body_decodes_gzip_and_deflate(self) -> None:
self.assertEqual( self.assertEqual(
read_response_body(_FakeResponse(gzip.compress(b'{"ok":1}'), "gzip")), read_response_body(_FakeResponse(gzip.compress(b'{"ok":1}'), "gzip")),
@ -461,10 +552,12 @@ class HttpBoundaryTests(unittest.TestCase):
time.sleep(0.01) time.sleep(0.01)
output = "\n".join(captured.output) output = "\n".join(captured.output)
self.assertEqual(status, 200) self.assertEqual(status, 200)
# Single-line stage records keep the log readable. self.assertIn("┌ request model=deepseek-v4-pro effort=max messages=1", output)
for marker in ("┌ cursor", "├ context", "├ send", "└ stats"): self.assertIn("├ context status=ok reasoning_context=0", output)
self.assertIn(marker, output) self.assertIn("└ stats", output)
self.assertNotIn("hi", output.split("┌ cursor")[1].split("\n")[0]) self.assertNotIn(" tools=", output)
self.assertNotIn("├ send", output)
self.assertNotIn("hi", output.split("┌ request")[1].split("\n")[0])
self.assertNotIn("sk-from-cursor", output) self.assertNotIn("sk-from-cursor", output)
def test_verbose_logging_includes_bodies_but_redacts_api_key(self) -> None: def test_verbose_logging_includes_bodies_but_redacts_api_key(self) -> None: