Skip to content

Commit ed4dd55

Browse files
committed
Merge branch 'dev' into main
2 parents 0c82aac + c745d73 commit ed4dd55

25 files changed

+8217
-3544
lines changed

DOCUMENTATION.md

Lines changed: 312 additions & 3 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 590 additions & 582 deletions
Large diffs are not rendered by default.

src/proxy_app/detailed_logger.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,60 @@
33
import uuid
44
from datetime import datetime
55
from pathlib import Path
6-
from typing import Any, Dict, Optional, List
6+
from typing import Any, Dict, Optional
77
import logging
88

9-
LOGS_DIR = Path(__file__).resolve().parent.parent.parent / "logs"
10-
DETAILED_LOGS_DIR = LOGS_DIR / "detailed_logs"
9+
from rotator_library.utils.resilient_io import (
10+
safe_write_json,
11+
safe_log_write,
12+
safe_mkdir,
13+
)
14+
from rotator_library.utils.paths import get_logs_dir
15+
16+
17+
def _get_detailed_logs_dir() -> Path:
18+
"""Get the detailed logs directory, creating it if needed."""
19+
logs_dir = get_logs_dir()
20+
detailed_dir = logs_dir / "detailed_logs"
21+
detailed_dir.mkdir(parents=True, exist_ok=True)
22+
return detailed_dir
23+
1124

1225
class DetailedLogger:
1326
"""
1427
Logs comprehensive details of each API transaction to a unique, timestamped directory.
28+
29+
Uses fire-and-forget logging - if disk writes fail, logs are dropped (not buffered)
30+
to prevent memory issues, especially with streaming responses.
1531
"""
32+
1633
def __init__(self):
1734
"""
1835
Initializes the logger for a single request, creating a unique directory to store all related log files.
1936
"""
2037
self.start_time = time.time()
2138
self.request_id = str(uuid.uuid4())
2239
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
23-
self.log_dir = DETAILED_LOGS_DIR / f"{timestamp}_{self.request_id}"
24-
self.log_dir.mkdir(parents=True, exist_ok=True)
40+
self.log_dir = _get_detailed_logs_dir() / f"{timestamp}_{self.request_id}"
2541
self.streaming = False
42+
self._dir_available = safe_mkdir(self.log_dir, logging)
2643

2744
def _write_json(self, filename: str, data: Dict[str, Any]):
2845
"""Helper to write data to a JSON file in the log directory."""
29-
try:
30-
with open(self.log_dir / filename, "w", encoding="utf-8") as f:
31-
json.dump(data, f, indent=4, ensure_ascii=False)
32-
except Exception as e:
33-
logging.error(f"[{self.request_id}] Failed to write to {filename}: {e}")
46+
if not self._dir_available:
47+
# Try to create directory again in case it was recreated
48+
self._dir_available = safe_mkdir(self.log_dir, logging)
49+
if not self._dir_available:
50+
return
51+
52+
safe_write_json(
53+
self.log_dir / filename,
54+
data,
55+
logging,
56+
atomic=False,
57+
indent=4,
58+
ensure_ascii=False,
59+
)
3460

3561
def log_request(self, headers: Dict[str, Any], body: Dict[str, Any]):
3662
"""Logs the initial request details."""
@@ -39,23 +65,22 @@ def log_request(self, headers: Dict[str, Any], body: Dict[str, Any]):
3965
"request_id": self.request_id,
4066
"timestamp_utc": datetime.utcnow().isoformat(),
4167
"headers": dict(headers),
42-
"body": body
68+
"body": body,
4369
}
4470
self._write_json("request.json", request_data)
4571

4672
def log_stream_chunk(self, chunk: Dict[str, Any]):
4773
"""Logs an individual chunk from a streaming response to a JSON Lines file."""
48-
try:
49-
log_entry = {
50-
"timestamp_utc": datetime.utcnow().isoformat(),
51-
"chunk": chunk
52-
}
53-
with open(self.log_dir / "streaming_chunks.jsonl", "a", encoding="utf-8") as f:
54-
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
55-
except Exception as e:
56-
logging.error(f"[{self.request_id}] Failed to write stream chunk: {e}")
57-
58-
def log_final_response(self, status_code: int, headers: Optional[Dict[str, Any]], body: Dict[str, Any]):
74+
if not self._dir_available:
75+
return
76+
77+
log_entry = {"timestamp_utc": datetime.utcnow().isoformat(), "chunk": chunk}
78+
content = json.dumps(log_entry, ensure_ascii=False) + "\n"
79+
safe_log_write(self.log_dir / "streaming_chunks.jsonl", content, logging)
80+
81+
def log_final_response(
82+
self, status_code: int, headers: Optional[Dict[str, Any]], body: Dict[str, Any]
83+
):
5984
"""Logs the complete final response, either from a non-streaming call or after reassembling a stream."""
6085
end_time = time.time()
6186
duration_ms = (end_time - self.start_time) * 1000
@@ -66,7 +91,7 @@ def log_final_response(self, status_code: int, headers: Optional[Dict[str, Any]]
6691
"status_code": status_code,
6792
"duration_ms": round(duration_ms),
6893
"headers": dict(headers) if headers else None,
69-
"body": body
94+
"body": body,
7095
}
7196
self._write_json("final_response.json", response_data)
7297
self._log_metadata(response_data)
@@ -75,10 +100,10 @@ def _extract_reasoning(self, response_body: Dict[str, Any]) -> Optional[str]:
75100
"""Recursively searches for and extracts 'reasoning' fields from the response body."""
76101
if not isinstance(response_body, dict):
77102
return None
78-
103+
79104
if "reasoning" in response_body:
80105
return response_body["reasoning"]
81-
106+
82107
if "choices" in response_body and response_body["choices"]:
83108
message = response_body["choices"][0].get("message", {})
84109
if "reasoning" in message:
@@ -93,8 +118,13 @@ def _log_metadata(self, response_data: Dict[str, Any]):
93118
usage = response_data.get("body", {}).get("usage") or {}
94119
model = response_data.get("body", {}).get("model", "N/A")
95120
finish_reason = "N/A"
96-
if "choices" in response_data.get("body", {}) and response_data["body"]["choices"]:
97-
finish_reason = response_data["body"]["choices"][0].get("finish_reason", "N/A")
121+
if (
122+
"choices" in response_data.get("body", {})
123+
and response_data["body"]["choices"]
124+
):
125+
finish_reason = response_data["body"]["choices"][0].get(
126+
"finish_reason", "N/A"
127+
)
98128

99129
metadata = {
100130
"request_id": self.request_id,
@@ -110,12 +140,12 @@ def _log_metadata(self, response_data: Dict[str, Any]):
110140
},
111141
"finish_reason": finish_reason,
112142
"reasoning_found": False,
113-
"reasoning_content": None
143+
"reasoning_content": None,
114144
}
115145

116146
reasoning = self._extract_reasoning(response_data.get("body", {}))
117147
if reasoning:
118148
metadata["reasoning_found"] = True
119149
metadata["reasoning_content"] = reasoning
120-
121-
self._write_json("metadata.json", metadata)
150+
151+
self._write_json("metadata.json", metadata)

src/proxy_app/launcher_tui.py

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@
1616
console = Console()
1717

1818

19+
def _get_env_file() -> Path:
20+
"""
21+
Get .env file path (lightweight - no heavy imports).
22+
23+
Returns:
24+
Path to .env file - EXE directory if frozen, else current working directory
25+
"""
26+
if getattr(sys, "frozen", False):
27+
# Running as PyInstaller EXE - use EXE's directory
28+
return Path(sys.executable).parent / ".env"
29+
# Running as script - use current working directory
30+
return Path.cwd() / ".env"
31+
32+
1933
def clear_screen():
2034
"""
2135
Cross-platform terminal clear that works robustly on both
@@ -74,7 +88,7 @@ def update(self, **kwargs):
7488
@staticmethod
7589
def update_proxy_api_key(new_key: str):
7690
"""Update PROXY_API_KEY in .env only"""
77-
env_file = Path.cwd() / ".env"
91+
env_file = _get_env_file()
7892
set_key(str(env_file), "PROXY_API_KEY", new_key)
7993
load_dotenv(dotenv_path=env_file, override=True)
8094

@@ -85,7 +99,7 @@ class SettingsDetector:
8599
@staticmethod
86100
def _load_local_env() -> dict:
87101
"""Load environment variables from local .env file only"""
88-
env_file = Path.cwd() / ".env"
102+
env_file = _get_env_file()
89103
env_dict = {}
90104
if not env_file.exists():
91105
return env_dict
@@ -107,7 +121,7 @@ def _load_local_env() -> dict:
107121

108122
@staticmethod
109123
def get_all_settings() -> dict:
110-
"""Returns comprehensive settings overview"""
124+
"""Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)"""
111125
return {
112126
"credentials": SettingsDetector.detect_credentials(),
113127
"custom_bases": SettingsDetector.detect_custom_api_bases(),
@@ -117,6 +131,17 @@ def get_all_settings() -> dict:
117131
"provider_settings": SettingsDetector.detect_provider_settings(),
118132
}
119133

134+
@staticmethod
135+
def get_basic_settings() -> dict:
136+
"""Returns basic settings overview without provider_settings (avoids heavy imports)"""
137+
return {
138+
"credentials": SettingsDetector.detect_credentials(),
139+
"custom_bases": SettingsDetector.detect_custom_api_bases(),
140+
"model_definitions": SettingsDetector.detect_model_definitions(),
141+
"concurrency_limits": SettingsDetector.detect_concurrency_limits(),
142+
"model_filters": SettingsDetector.detect_model_filters(),
143+
}
144+
120145
@staticmethod
121146
def detect_credentials() -> dict:
122147
"""Detect API keys and OAuth credentials"""
@@ -260,7 +285,7 @@ def __init__(self):
260285
self.console = Console()
261286
self.config = LauncherConfig()
262287
self.running = True
263-
self.env_file = Path.cwd() / ".env"
288+
self.env_file = _get_env_file()
264289
# Load .env file to ensure environment variables are available
265290
load_dotenv(dotenv_path=self.env_file, override=True)
266291

@@ -277,8 +302,8 @@ def show_main_menu(self):
277302
"""Display main menu and handle selection"""
278303
clear_screen()
279304

280-
# Detect all settings
281-
settings = SettingsDetector.get_all_settings()
305+
# Detect basic settings (excludes provider_settings to avoid heavy imports)
306+
settings = SettingsDetector.get_basic_settings()
282307
credentials = settings["credentials"]
283308
custom_bases = settings["custom_bases"]
284309

@@ -363,18 +388,17 @@ def show_main_menu(self):
363388
self.console.print("━" * 70)
364389
provider_count = len(credentials)
365390
custom_count = len(custom_bases)
366-
provider_settings = settings.get("provider_settings", {})
391+
392+
self.console.print(f" Providers: {provider_count} configured")
393+
self.console.print(f" Custom Providers: {custom_count} configured")
394+
# Note: provider_settings detection is deferred to avoid heavy imports on startup
367395
has_advanced = bool(
368396
settings["model_definitions"]
369397
or settings["concurrency_limits"]
370398
or settings["model_filters"]
371-
or provider_settings
372399
)
373-
374-
self.console.print(f" Providers: {provider_count} configured")
375-
self.console.print(f" Custom Providers: {custom_count} configured")
376400
self.console.print(
377-
f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None'}"
401+
f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}"
378402
)
379403

380404
# Show menu
@@ -418,7 +442,7 @@ def show_main_menu(self):
418442
elif choice == "4":
419443
self.show_provider_settings_menu()
420444
elif choice == "5":
421-
load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
445+
load_dotenv(dotenv_path=_get_env_file(), override=True)
422446
self.config = LauncherConfig() # Reload config
423447
self.console.print("\n[green]✅ Configuration reloaded![/green]")
424448
elif choice == "6":
@@ -659,13 +683,14 @@ def show_provider_settings_menu(self):
659683
"""Display provider/advanced settings (read-only + launch tool)"""
660684
clear_screen()
661685

662-
settings = SettingsDetector.get_all_settings()
686+
# Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool
687+
settings = SettingsDetector.get_basic_settings()
688+
663689
credentials = settings["credentials"]
664690
custom_bases = settings["custom_bases"]
665691
model_defs = settings["model_definitions"]
666692
concurrency = settings["concurrency_limits"]
667693
filters = settings["model_filters"]
668-
provider_settings = settings.get("provider_settings", {})
669694

670695
self.console.print(
671696
Panel.fit(
@@ -740,23 +765,13 @@ def show_provider_settings_menu(self):
740765
status = " + ".join(status_parts) if status_parts else "None"
741766
self.console.print(f" • {provider:15}{status}")
742767

743-
# Provider-Specific Settings
768+
# Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports)
744769
self.console.print()
745770
self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
746771
self.console.print("━" * 70)
747-
try:
748-
from proxy_app.settings_tool import PROVIDER_SETTINGS_MAP
749-
except ImportError:
750-
from .settings_tool import PROVIDER_SETTINGS_MAP
751-
for provider in PROVIDER_SETTINGS_MAP.keys():
752-
display_name = provider.replace("_", " ").title()
753-
modified = provider_settings.get(provider, 0)
754-
if modified > 0:
755-
self.console.print(
756-
f" • {display_name:20} [yellow]{modified} setting{'s' if modified > 1 else ''} modified[/yellow]"
757-
)
758-
else:
759-
self.console.print(f" • {display_name:20} [dim]using defaults[/dim]")
772+
self.console.print(
773+
" [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]"
774+
)
760775

761776
# Actions
762777
self.console.print()
@@ -823,15 +838,31 @@ def launch_credential_tool(self):
823838
# Run the tool with from_launcher=True to skip duplicate loading screen
824839
run_credential_tool(from_launcher=True)
825840
# Reload environment after credential tool
826-
load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
841+
load_dotenv(dotenv_path=_get_env_file(), override=True)
827842

828843
def launch_settings_tool(self):
829844
"""Launch settings configuration tool"""
830-
from proxy_app.settings_tool import run_settings_tool
845+
import time
846+
847+
clear_screen()
848+
849+
self.console.print("━" * 70)
850+
self.console.print("Advanced Settings Configuration Tool")
851+
self.console.print("━" * 70)
852+
853+
_start_time = time.time()
854+
855+
with self.console.status("Initializing settings tool...", spinner="dots"):
856+
from proxy_app.settings_tool import run_settings_tool
857+
858+
_elapsed = time.time() - _start_time
859+
self.console.print(f"✓ Settings tool ready in {_elapsed:.2f}s")
860+
861+
time.sleep(0.3)
831862

832863
run_settings_tool()
833864
# Reload environment after settings tool
834-
load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
865+
load_dotenv(dotenv_path=_get_env_file(), override=True)
835866

836867
def show_about(self):
837868
"""Display About page with project information"""
@@ -919,9 +950,9 @@ def run_proxy(self):
919950
)
920951

921952
ensure_env_defaults()
922-
load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
953+
load_dotenv(dotenv_path=_get_env_file(), override=True)
923954
run_credential_tool()
924-
load_dotenv(dotenv_path=Path.cwd() / ".env", override=True)
955+
load_dotenv(dotenv_path=_get_env_file(), override=True)
925956

926957
# Check again after credential tool
927958
if not os.getenv("PROXY_API_KEY"):

0 commit comments

Comments
 (0)