local-openai2anthropic 0.1.0__py3-none-any.whl → 0.2.3__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/__init__.py +1 -1
- local_openai2anthropic/__main__.py +7 -0
- local_openai2anthropic/config.py +2 -2
- local_openai2anthropic/converter.py +28 -193
- local_openai2anthropic/daemon.py +382 -0
- local_openai2anthropic/daemon_runner.py +116 -0
- local_openai2anthropic/main.py +177 -25
- local_openai2anthropic/openai_types.py +149 -0
- local_openai2anthropic/router.py +75 -16
- local_openai2anthropic-0.2.3.dist-info/METADATA +351 -0
- local_openai2anthropic-0.2.3.dist-info/RECORD +19 -0
- local_openai2anthropic-0.1.0.dist-info/METADATA +0 -689
- local_openai2anthropic-0.1.0.dist-info/RECORD +0 -15
- {local_openai2anthropic-0.1.0.dist-info → local_openai2anthropic-0.2.3.dist-info}/WHEEL +0 -0
- {local_openai2anthropic-0.1.0.dist-info → local_openai2anthropic-0.2.3.dist-info}/entry_points.txt +0 -0
- {local_openai2anthropic-0.1.0.dist-info → local_openai2anthropic-0.2.3.dist-info}/licenses/LICENSE +0 -0
local_openai2anthropic/config.py
CHANGED
|
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
# OpenAI API Configuration
|
|
23
|
-
openai_api_key: str
|
|
23
|
+
openai_api_key: Optional[str] = None
|
|
24
24
|
openai_base_url: str = "https://api.openai.com/v1"
|
|
25
25
|
openai_org_id: Optional[str] = None
|
|
26
26
|
openai_project_id: Optional[str] = None
|
|
@@ -40,7 +40,7 @@ class Settings(BaseSettings):
|
|
|
40
40
|
cors_headers: list[str] = ["*"]
|
|
41
41
|
|
|
42
42
|
# Logging
|
|
43
|
-
log_level: str = "
|
|
43
|
+
log_level: str = "DEBUG"
|
|
44
44
|
|
|
45
45
|
# Tavily Web Search Configuration
|
|
46
46
|
tavily_api_key: Optional[str] = None
|
|
@@ -5,23 +5,13 @@ Core conversion logic between Anthropic and OpenAI formats.
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
-
import
|
|
9
|
-
from typing import Any, AsyncGenerator, Optional
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
8
|
+
from typing import Any, Optional
|
|
12
9
|
|
|
13
10
|
from anthropic.types import (
|
|
14
11
|
ContentBlock,
|
|
15
|
-
ContentBlockDeltaEvent,
|
|
16
|
-
ContentBlockStartEvent,
|
|
17
|
-
ContentBlockStopEvent,
|
|
18
12
|
Message,
|
|
19
|
-
MessageDeltaEvent,
|
|
20
13
|
MessageParam,
|
|
21
|
-
MessageStartEvent,
|
|
22
|
-
MessageStopEvent,
|
|
23
14
|
TextBlock,
|
|
24
|
-
TextDelta,
|
|
25
15
|
ToolUseBlock,
|
|
26
16
|
)
|
|
27
17
|
from anthropic.types.message_create_params import MessageCreateParams
|
|
@@ -175,11 +165,15 @@ def convert_anthropic_to_openai(
|
|
|
175
165
|
|
|
176
166
|
# Handle thinking parameter
|
|
177
167
|
# vLLM/SGLang use chat_template_kwargs.thinking to toggle thinking mode
|
|
168
|
+
# Some models use "thinking", others use "enable_thinking", so we include both
|
|
178
169
|
if thinking and isinstance(thinking, dict):
|
|
179
170
|
thinking_type = thinking.get("type")
|
|
180
171
|
if thinking_type == "enabled":
|
|
181
|
-
# Enable thinking mode for
|
|
182
|
-
params["chat_template_kwargs"] = {
|
|
172
|
+
# Enable thinking mode - include both variants for compatibility
|
|
173
|
+
params["chat_template_kwargs"] = {
|
|
174
|
+
"thinking": True,
|
|
175
|
+
"enable_thinking": True,
|
|
176
|
+
}
|
|
183
177
|
|
|
184
178
|
# Log if budget_tokens was provided but will be ignored
|
|
185
179
|
budget_tokens = thinking.get("budget_tokens")
|
|
@@ -191,10 +185,16 @@ def convert_anthropic_to_openai(
|
|
|
191
185
|
)
|
|
192
186
|
else:
|
|
193
187
|
# Default to disabled thinking mode if not explicitly enabled
|
|
194
|
-
params["chat_template_kwargs"] = {
|
|
188
|
+
params["chat_template_kwargs"] = {
|
|
189
|
+
"thinking": False,
|
|
190
|
+
"enable_thinking": False,
|
|
191
|
+
}
|
|
195
192
|
else:
|
|
196
193
|
# Default to disabled thinking mode when thinking is not provided
|
|
197
|
-
params["chat_template_kwargs"] = {
|
|
194
|
+
params["chat_template_kwargs"] = {
|
|
195
|
+
"thinking": False,
|
|
196
|
+
"enable_thinking": False,
|
|
197
|
+
}
|
|
198
198
|
|
|
199
199
|
# Store server tool configs for later use by router
|
|
200
200
|
if server_tools_config:
|
|
@@ -361,12 +361,25 @@ def convert_openai_to_anthropic(
|
|
|
361
361
|
Returns:
|
|
362
362
|
Anthropic Message response
|
|
363
363
|
"""
|
|
364
|
+
from anthropic.types.beta import BetaThinkingBlock
|
|
365
|
+
|
|
364
366
|
choice = completion.choices[0]
|
|
365
367
|
message = choice.message
|
|
366
368
|
|
|
367
369
|
# Convert content blocks
|
|
368
370
|
content: list[ContentBlock] = []
|
|
369
371
|
|
|
372
|
+
# Add reasoning content (thinking) first if present
|
|
373
|
+
reasoning_content = getattr(message, 'reasoning_content', None)
|
|
374
|
+
if reasoning_content:
|
|
375
|
+
content.append(
|
|
376
|
+
BetaThinkingBlock(
|
|
377
|
+
type="thinking",
|
|
378
|
+
thinking=reasoning_content,
|
|
379
|
+
signature="", # Signature not available from OpenAI format
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
370
383
|
# Add text content if present
|
|
371
384
|
if message.content:
|
|
372
385
|
if isinstance(message.content, str):
|
|
@@ -426,181 +439,3 @@ def convert_openai_to_anthropic(
|
|
|
426
439
|
}
|
|
427
440
|
|
|
428
441
|
return Message.model_validate(message_dict)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
async def convert_openai_stream_to_anthropic(
|
|
432
|
-
stream: AsyncGenerator[ChatCompletionChunk, None],
|
|
433
|
-
model: str,
|
|
434
|
-
enable_ping: bool = False,
|
|
435
|
-
ping_interval: float = 15.0,
|
|
436
|
-
) -> AsyncGenerator[dict, None]:
|
|
437
|
-
"""
|
|
438
|
-
Convert OpenAI streaming response to Anthropic streaming events.
|
|
439
|
-
|
|
440
|
-
Args:
|
|
441
|
-
stream: OpenAI chat completion stream
|
|
442
|
-
model: Model name
|
|
443
|
-
enable_ping: Whether to send periodic ping events
|
|
444
|
-
ping_interval: Interval between ping events in seconds
|
|
445
|
-
|
|
446
|
-
Yields:
|
|
447
|
-
Anthropic MessageStreamEvent objects as dicts
|
|
448
|
-
"""
|
|
449
|
-
message_id = f"msg_{int(time.time() * 1000)}"
|
|
450
|
-
first_chunk = True
|
|
451
|
-
content_block_started = False
|
|
452
|
-
content_block_index = 0
|
|
453
|
-
current_tool_call: Optional[dict[str, Any]] = None
|
|
454
|
-
finish_reason: Optional[str] = None
|
|
455
|
-
|
|
456
|
-
# Track usage for final message_delta
|
|
457
|
-
input_tokens = 0
|
|
458
|
-
output_tokens = 0
|
|
459
|
-
|
|
460
|
-
last_ping_time = time.time()
|
|
461
|
-
|
|
462
|
-
async for chunk in stream:
|
|
463
|
-
# Send ping events if enabled and interval has passed
|
|
464
|
-
if enable_ping:
|
|
465
|
-
current_time = time.time()
|
|
466
|
-
if current_time - last_ping_time >= ping_interval:
|
|
467
|
-
yield {"type": "ping"}
|
|
468
|
-
last_ping_time = current_time
|
|
469
|
-
|
|
470
|
-
# First chunk: message_start event
|
|
471
|
-
if first_chunk:
|
|
472
|
-
if chunk.usage:
|
|
473
|
-
input_tokens = chunk.usage.prompt_tokens
|
|
474
|
-
output_tokens = chunk.usage.completion_tokens
|
|
475
|
-
|
|
476
|
-
yield {
|
|
477
|
-
"type": "message_start",
|
|
478
|
-
"message": {
|
|
479
|
-
"id": message_id,
|
|
480
|
-
"type": "message",
|
|
481
|
-
"role": "assistant",
|
|
482
|
-
"content": [],
|
|
483
|
-
"model": model,
|
|
484
|
-
"stop_reason": None,
|
|
485
|
-
"stop_sequence": None,
|
|
486
|
-
"usage": {
|
|
487
|
-
"input_tokens": input_tokens,
|
|
488
|
-
"output_tokens": 0,
|
|
489
|
-
"cache_creation_input_tokens": None,
|
|
490
|
-
"cache_read_input_tokens": None,
|
|
491
|
-
},
|
|
492
|
-
},
|
|
493
|
-
}
|
|
494
|
-
first_chunk = False
|
|
495
|
-
continue
|
|
496
|
-
|
|
497
|
-
# Handle usage-only chunks (last chunk)
|
|
498
|
-
if not chunk.choices:
|
|
499
|
-
if chunk.usage:
|
|
500
|
-
input_tokens = chunk.usage.prompt_tokens
|
|
501
|
-
output_tokens = chunk.usage.completion_tokens
|
|
502
|
-
|
|
503
|
-
# Close any open content block
|
|
504
|
-
if content_block_started:
|
|
505
|
-
yield {
|
|
506
|
-
"type": "content_block_stop",
|
|
507
|
-
"index": content_block_index,
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
# Message delta with final usage
|
|
511
|
-
stop_reason_map = {
|
|
512
|
-
"stop": "end_turn",
|
|
513
|
-
"length": "max_tokens",
|
|
514
|
-
"tool_calls": "tool_use",
|
|
515
|
-
}
|
|
516
|
-
yield {
|
|
517
|
-
"type": "message_delta",
|
|
518
|
-
"delta": {
|
|
519
|
-
"stop_reason": stop_reason_map.get(finish_reason or "stop", "end_turn"),
|
|
520
|
-
},
|
|
521
|
-
"usage": {
|
|
522
|
-
"input_tokens": input_tokens,
|
|
523
|
-
"output_tokens": output_tokens,
|
|
524
|
-
"cache_creation_input_tokens": getattr(chunk.usage, "cache_creation_input_tokens", None),
|
|
525
|
-
"cache_read_input_tokens": getattr(chunk.usage, "cache_read_input_tokens", None),
|
|
526
|
-
},
|
|
527
|
-
}
|
|
528
|
-
continue
|
|
529
|
-
|
|
530
|
-
choice = chunk.choices[0]
|
|
531
|
-
delta = choice.delta
|
|
532
|
-
|
|
533
|
-
# Track finish reason
|
|
534
|
-
if choice.finish_reason:
|
|
535
|
-
finish_reason = choice.finish_reason
|
|
536
|
-
continue
|
|
537
|
-
|
|
538
|
-
# Handle content
|
|
539
|
-
if delta.content:
|
|
540
|
-
if not content_block_started:
|
|
541
|
-
# Start text content block
|
|
542
|
-
yield {
|
|
543
|
-
"type": "content_block_start",
|
|
544
|
-
"index": content_block_index,
|
|
545
|
-
"content_block": {"type": "text", "text": ""},
|
|
546
|
-
}
|
|
547
|
-
content_block_started = True
|
|
548
|
-
|
|
549
|
-
if delta.content:
|
|
550
|
-
yield {
|
|
551
|
-
"type": "content_block_delta",
|
|
552
|
-
"index": content_block_index,
|
|
553
|
-
"delta": {"type": "text_delta", "text": delta.content},
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
# Handle tool calls
|
|
557
|
-
if delta.tool_calls:
|
|
558
|
-
tool_call = delta.tool_calls[0]
|
|
559
|
-
|
|
560
|
-
if tool_call.id:
|
|
561
|
-
# Close previous content block if any
|
|
562
|
-
if content_block_started:
|
|
563
|
-
yield {
|
|
564
|
-
"type": "content_block_stop",
|
|
565
|
-
"index": content_block_index,
|
|
566
|
-
}
|
|
567
|
-
content_block_started = False
|
|
568
|
-
content_block_index += 1
|
|
569
|
-
|
|
570
|
-
# Start new tool_use block
|
|
571
|
-
current_tool_call = {
|
|
572
|
-
"id": tool_call.id,
|
|
573
|
-
"name": tool_call.function.name if tool_call.function else "",
|
|
574
|
-
"arguments": "",
|
|
575
|
-
}
|
|
576
|
-
yield {
|
|
577
|
-
"type": "content_block_start",
|
|
578
|
-
"index": content_block_index,
|
|
579
|
-
"content_block": {
|
|
580
|
-
"type": "tool_use",
|
|
581
|
-
"id": tool_call.id,
|
|
582
|
-
"name": tool_call.function.name if tool_call.function else "",
|
|
583
|
-
"input": {},
|
|
584
|
-
},
|
|
585
|
-
}
|
|
586
|
-
content_block_started = True
|
|
587
|
-
|
|
588
|
-
elif tool_call.function and tool_call.function.arguments:
|
|
589
|
-
# Continue tool call arguments
|
|
590
|
-
args = tool_call.function.arguments
|
|
591
|
-
current_tool_call["arguments"] += args
|
|
592
|
-
yield {
|
|
593
|
-
"type": "content_block_delta",
|
|
594
|
-
"index": content_block_index,
|
|
595
|
-
"delta": {"type": "input_json_delta", "partial_json": args},
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
# Close final content block
|
|
599
|
-
if content_block_started:
|
|
600
|
-
yield {
|
|
601
|
-
"type": "content_block_stop",
|
|
602
|
-
"index": content_block_index,
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
# Message stop event
|
|
606
|
-
yield {"type": "message_stop"}
|
|
@@ -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
|
+
)
|