local-openai2anthropic 0.1.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Allow running as python -m local_openai2anthropic"""
3
+
4
+ from local_openai2anthropic.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,382 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Daemon process management for local-openai2anthropic server.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import signal
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ # Constants
17
+ DATA_DIR = Path.home() / ".local" / "share" / "oa2a"
18
+ PID_FILE = DATA_DIR / "oa2a.pid"
19
+ CONFIG_FILE = DATA_DIR / "oa2a.json"
20
+ LOG_FILE = DATA_DIR / "oa2a.log"
21
+
22
+
23
+ def _ensure_dirs() -> None:
24
+ """Ensure pid/log directories exist."""
25
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+
28
+ def _read_pid() -> Optional[int]:
29
+ """Read PID from pidfile."""
30
+ try:
31
+ if PID_FILE.exists():
32
+ return int(PID_FILE.read_text().strip())
33
+ except (ValueError, OSError):
34
+ pass
35
+ return None
36
+
37
+
38
+ def _remove_pid() -> None:
39
+ """Remove pidfile."""
40
+ try:
41
+ if PID_FILE.exists():
42
+ PID_FILE.unlink()
43
+ except OSError:
44
+ pass
45
+
46
+
47
+ def _save_daemon_config(host: str, port: int) -> None:
48
+ """Save daemon configuration to file."""
49
+ _ensure_dirs()
50
+ config = {
51
+ "host": host,
52
+ "port": port,
53
+ "started_at": time.time(),
54
+ }
55
+ try:
56
+ CONFIG_FILE.write_text(json.dumps(config))
57
+ except OSError:
58
+ pass
59
+
60
+
61
+ def _load_daemon_config() -> Optional[dict]:
62
+ """Load daemon configuration from file."""
63
+ try:
64
+ if CONFIG_FILE.exists():
65
+ return json.loads(CONFIG_FILE.read_text())
66
+ except (OSError, json.JSONDecodeError):
67
+ pass
68
+ return None
69
+
70
+
71
+ def _remove_daemon_config() -> None:
72
+ """Remove daemon configuration file."""
73
+ try:
74
+ if CONFIG_FILE.exists():
75
+ CONFIG_FILE.unlink()
76
+ except OSError:
77
+ pass
78
+
79
+
80
+ def _is_process_running(pid: int) -> bool:
81
+ """Check if a process with given PID is running."""
82
+ try:
83
+ os.kill(pid, 0)
84
+ return True
85
+ except (OSError, ProcessLookupError):
86
+ return False
87
+
88
+
89
+ def _is_port_in_use(port: int, host: str = "0.0.0.0") -> bool:
90
+ """Check if a port is already in use."""
91
+ try:
92
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
93
+ s.settimeout(1)
94
+ result = s.connect_ex((host, port))
95
+ return result == 0
96
+ except Exception:
97
+ return False
98
+
99
+
100
+ def _cleanup_stale_pidfile() -> None:
101
+ """Remove pidfile if the process is not running."""
102
+ pid = _read_pid()
103
+ if pid is not None and not _is_process_running(pid):
104
+ _remove_pid()
105
+ _remove_daemon_config()
106
+
107
+
108
+ def get_status() -> tuple[bool, Optional[int], Optional[dict]]:
109
+ """
110
+ Get daemon status.
111
+
112
+ Returns:
113
+ Tuple of (is_running, pid, config)
114
+ """
115
+ _cleanup_stale_pidfile()
116
+ pid = _read_pid()
117
+ config = _load_daemon_config()
118
+ if pid is not None and _is_process_running(pid):
119
+ return True, pid, config
120
+ return False, None, None
121
+
122
+
123
+ def start_daemon(
124
+ host: str = "0.0.0.0",
125
+ port: int = 8080,
126
+ log_level: str = "info",
127
+ ) -> bool:
128
+ """
129
+ Start the server as a background daemon.
130
+
131
+ Args:
132
+ host: Server host
133
+ port: Server port
134
+ log_level: Logging level
135
+
136
+ Returns:
137
+ True if started successfully, False otherwise
138
+ """
139
+ _cleanup_stale_pidfile()
140
+
141
+ pid = _read_pid()
142
+ if pid is not None:
143
+ config = _load_daemon_config()
144
+ actual_port = config.get("port", port) if config else port
145
+ print(f"Server is already running (PID: {pid}, port: {actual_port})", file=sys.stderr)
146
+ print(f"Use 'oa2a logs' to view output", file=sys.stderr)
147
+ return False
148
+
149
+ # Check if port is already in use
150
+ if _is_port_in_use(port):
151
+ print(f"Error: Port {port} is already in use", file=sys.stderr)
152
+ print(f"Another process may be listening on this port", file=sys.stderr)
153
+ return False
154
+
155
+ _ensure_dirs()
156
+
157
+ # Prepare the command to run the daemon runner as a separate script
158
+ daemon_runner_path = Path(__file__).parent / "daemon_runner.py"
159
+
160
+ # Prepare environment - the daemon runner will use these env vars
161
+ env = os.environ.copy()
162
+ env["OA2A_HOST"] = host
163
+ env["OA2A_PORT"] = str(port)
164
+ env["OA2A_LOG_LEVEL"] = log_level.upper()
165
+
166
+ cmd = [
167
+ sys.executable,
168
+ str(daemon_runner_path),
169
+ ]
170
+
171
+ try:
172
+ # Open log file
173
+ log_fd = open(LOG_FILE, "a")
174
+
175
+ # Write a marker to log
176
+ from datetime import datetime
177
+ log_fd.write(f"\n\n[{datetime.now()}] Starting oa2a daemon...\n")
178
+ log_fd.flush()
179
+
180
+ # Start the process - use setsid on Unix to create new session
181
+ kwargs = {
182
+ "stdout": log_fd,
183
+ "stderr": subprocess.STDOUT,
184
+ "env": env,
185
+ }
186
+
187
+ if sys.platform != "win32":
188
+ # On Unix, start in a new session so it survives parent exit
189
+ kwargs["start_new_session"] = True
190
+
191
+ process = subprocess.Popen(cmd, **kwargs)
192
+
193
+ # Don't wait - close file descriptor in parent but child keeps it open
194
+ log_fd.close()
195
+
196
+ # Give the process a moment to fail (e.g., port in use)
197
+ time.sleep(0.5)
198
+
199
+ # Check if process is still running
200
+ if process.poll() is not None:
201
+ # Process exited immediately
202
+ print("Failed to start server - check logs with 'oa2a logs'", file=sys.stderr)
203
+ return False
204
+
205
+ # Wait a bit more for the server to actually start
206
+ time.sleep(0.5)
207
+
208
+ # Check if port is now in use (server started successfully)
209
+ for _ in range(10):
210
+ if _is_port_in_use(port, "127.0.0.1"):
211
+ break
212
+ time.sleep(0.2)
213
+ else:
214
+ # Port never became active, check if process died
215
+ if process.poll() is not None:
216
+ print("Server process exited unexpectedly - check logs", file=sys.stderr)
217
+ return False
218
+
219
+ # Save the configuration
220
+ _save_daemon_config(host, port)
221
+
222
+ print(f"Server started (PID: {process.pid})")
223
+ print(f"Listening on {host}:{port}")
224
+ print(f"Logs: {LOG_FILE}")
225
+
226
+ return True
227
+
228
+ except Exception as e:
229
+ print(f"Failed to start server: {e}", file=sys.stderr)
230
+ return False
231
+
232
+
233
+ def stop_daemon(force: bool = False) -> bool:
234
+ """
235
+ Stop the background daemon.
236
+
237
+ Args:
238
+ force: If True, use SIGKILL instead of SIGTERM
239
+
240
+ Returns:
241
+ True if stopped successfully, False otherwise
242
+ """
243
+ _cleanup_stale_pidfile()
244
+
245
+ pid = _read_pid()
246
+ if pid is None:
247
+ print("Server is not running")
248
+ return True
249
+
250
+ try:
251
+ # Send signal
252
+ signal_num = signal.SIGKILL if force else signal.SIGTERM
253
+ os.kill(pid, signal_num)
254
+
255
+ # Wait for process to terminate
256
+ for _ in range(50): # Wait up to 5 seconds
257
+ if not _is_process_running(pid):
258
+ break
259
+ time.sleep(0.1)
260
+
261
+ if _is_process_running(pid):
262
+ if not force:
263
+ print(f"Server did not stop gracefully, use -f to force kill", file=sys.stderr)
264
+ return False
265
+ # Force kill
266
+ os.kill(pid, signal.SIGKILL)
267
+ time.sleep(0.2)
268
+
269
+ _remove_pid()
270
+ _remove_daemon_config()
271
+ print(f"Server stopped (PID: {pid})")
272
+ return True
273
+
274
+ except (OSError, ProcessLookupError) as e:
275
+ _remove_pid()
276
+ _remove_daemon_config()
277
+ print(f"Server stopped (PID: {pid})")
278
+ return True
279
+ except Exception as e:
280
+ print(f"Failed to stop server: {e}", file=sys.stderr)
281
+ return False
282
+
283
+
284
+ def restart_daemon(
285
+ host: str = "0.0.0.0",
286
+ port: int = 8080,
287
+ log_level: str = "info",
288
+ ) -> bool:
289
+ """
290
+ Restart the background daemon.
291
+
292
+ Args:
293
+ host: Server host
294
+ port: Server port
295
+ log_level: Logging level
296
+
297
+ Returns:
298
+ True if restarted successfully, False otherwise
299
+ """
300
+ print("Restarting server...")
301
+ stop_daemon()
302
+ # Small delay to ensure port is released
303
+ time.sleep(0.5)
304
+ return start_daemon(host, port, log_level)
305
+
306
+
307
+ def show_logs(follow: bool = False, lines: int = 50) -> bool:
308
+ """
309
+ Show server logs.
310
+
311
+ Args:
312
+ follow: If True, follow log output (like tail -f)
313
+ lines: Number of lines to show from the end
314
+
315
+ Returns:
316
+ True if successful, False otherwise
317
+ """
318
+ if not LOG_FILE.exists():
319
+ print("No log file found", file=sys.stderr)
320
+ return False
321
+
322
+ try:
323
+ if follow:
324
+ # Use subprocess to tail -f
325
+ try:
326
+ subprocess.run(
327
+ ["tail", "-f", "-n", str(lines), str(LOG_FILE)],
328
+ check=True,
329
+ )
330
+ except KeyboardInterrupt:
331
+ pass
332
+ else:
333
+ # Read and print last N lines
334
+ with open(LOG_FILE, "r") as f:
335
+ content = f.readlines()
336
+ # Print last N lines
337
+ for line in content[-lines:]:
338
+ print(line, end="")
339
+
340
+ return True
341
+
342
+ except Exception as e:
343
+ print(f"Failed to read logs: {e}", file=sys.stderr)
344
+ return False
345
+
346
+
347
+ def run_foreground(
348
+ host: str = "0.0.0.0",
349
+ port: int = 8080,
350
+ log_level: str = "info",
351
+ ) -> None:
352
+ """
353
+ Run the server in foreground (blocking mode).
354
+
355
+ This is the original behavior for compatibility.
356
+ """
357
+ # Import here to avoid circular imports
358
+ from local_openai2anthropic.main import create_app
359
+ from local_openai2anthropic.config import get_settings
360
+
361
+ import uvicorn
362
+
363
+ # Override settings with command line values
364
+ os.environ["OA2A_HOST"] = host
365
+ os.environ["OA2A_PORT"] = str(port)
366
+ os.environ["OA2A_LOG_LEVEL"] = log_level.upper()
367
+
368
+ settings = get_settings()
369
+
370
+ app = create_app(settings)
371
+
372
+ print(f"Starting server on {host}:{port}")
373
+ print(f"Proxying to: {settings.openai_base_url}")
374
+ print("Press Ctrl+C to stop")
375
+
376
+ uvicorn.run(
377
+ app,
378
+ host=host,
379
+ port=port,
380
+ log_level=log_level.lower(),
381
+ timeout_keep_alive=300,
382
+ )
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ Daemon runner - this module is executed as a standalone script in the child process.
5
+ """
6
+
7
+ import atexit
8
+ import os
9
+ import signal
10
+ import sys
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+
14
+ PID_FILE = Path.home() / ".local" / "share" / "oa2a" / "oa2a.pid"
15
+
16
+
17
+ def log_message(msg: str) -> None:
18
+ """Write message to both stdout and parent process communication"""
19
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
20
+ line = f"[{timestamp}] {msg}"
21
+ print(line, file=sys.stderr)
22
+ sys.stderr.flush()
23
+
24
+
25
+ def _write_pid(pid: int) -> None:
26
+ """Write PID to pidfile."""
27
+ try:
28
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
29
+ PID_FILE.write_text(str(pid))
30
+ log_message(f"PID written to {PID_FILE}: {pid}")
31
+ except Exception as e:
32
+ log_message(f"Failed to write PID file: {e}")
33
+
34
+
35
+ def _remove_pid() -> None:
36
+ """Remove pidfile."""
37
+ try:
38
+ if PID_FILE.exists():
39
+ PID_FILE.unlink()
40
+ log_message(f"PID file removed: {PID_FILE}")
41
+ except OSError:
42
+ pass
43
+
44
+
45
+ def _signal_handler(signum, frame):
46
+ """Handle termination signals."""
47
+ sig_name = signal.Signals(signum).name
48
+ log_message(f"Received signal {sig_name}, shutting down...")
49
+ _remove_pid()
50
+ sys.exit(0)
51
+
52
+
53
+ def run_server():
54
+ """Run the server in daemon mode."""
55
+ try:
56
+ # Write current PID to file (this is the correct PID for the daemon)
57
+ current_pid = os.getpid()
58
+ _write_pid(current_pid)
59
+
60
+ # Register cleanup on exit
61
+ atexit.register(_remove_pid)
62
+
63
+ # Setup signal handlers
64
+ signal.signal(signal.SIGTERM, _signal_handler)
65
+ signal.signal(signal.SIGINT, _signal_handler)
66
+
67
+ log_message(f"Starting daemon server (PID: {current_pid})...")
68
+
69
+ # Add the src directory to path if needed
70
+ current_file = Path(__file__).resolve()
71
+ package_dir = current_file.parent
72
+
73
+ if str(package_dir) not in sys.path:
74
+ sys.path.insert(0, str(package_dir))
75
+
76
+ # Import and run the main server
77
+ from local_openai2anthropic.main import create_app
78
+ from local_openai2anthropic.config import get_settings
79
+
80
+ import uvicorn
81
+
82
+ settings = get_settings()
83
+
84
+ log_message(f"Configuration loaded:")
85
+ log_message(f" Host: {settings.host}")
86
+ log_message(f" Port: {settings.port}")
87
+ log_message(f" Log Level: {settings.log_level}")
88
+ log_message(f" OpenAI Base URL: {settings.openai_base_url}")
89
+
90
+ # Validate required settings
91
+ if not settings.openai_api_key:
92
+ log_message("Error: OA2A_OPENAI_API_KEY is required but not set")
93
+ sys.exit(1)
94
+
95
+ app = create_app(settings)
96
+
97
+ log_message(f"Starting uvicorn on {settings.host}:{settings.port}")
98
+
99
+ uvicorn.run(
100
+ app,
101
+ host=settings.host,
102
+ port=settings.port,
103
+ log_level=settings.log_level.lower(),
104
+ timeout_keep_alive=300,
105
+ )
106
+
107
+ except Exception as e:
108
+ log_message(f"Fatal error in daemon: {e}")
109
+ import traceback
110
+ traceback.print_exc()
111
+ _remove_pid()
112
+ sys.exit(1)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ run_server()