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.
- local_openai2anthropic/__main__.py +7 -0
- local_openai2anthropic/daemon.py +382 -0
- local_openai2anthropic/daemon_runner.py +116 -0
- local_openai2anthropic/main.py +173 -34
- local_openai2anthropic-0.2.0.dist-info/METADATA +351 -0
- {local_openai2anthropic-0.1.1.dist-info → local_openai2anthropic-0.2.0.dist-info}/RECORD +9 -6
- local_openai2anthropic-0.1.1.dist-info/METADATA +0 -689
- {local_openai2anthropic-0.1.1.dist-info → local_openai2anthropic-0.2.0.dist-info}/WHEEL +0 -0
- {local_openai2anthropic-0.1.1.dist-info → local_openai2anthropic-0.2.0.dist-info}/entry_points.txt +0 -0
- {local_openai2anthropic-0.1.1.dist-info → local_openai2anthropic-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|