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.
@@ -3,7 +3,7 @@
3
3
  local-openai2anthropic: A proxy server that converts Anthropic Messages API to OpenAI API.
4
4
  """
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.3"
7
7
 
8
8
  from local_openai2anthropic.protocol import (
9
9
  AnthropicError,
@@ -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()
@@ -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 = "INFO"
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 time
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 vLLM/SGLang
182
- params["chat_template_kwargs"] = {"thinking": True}
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"] = {"thinking": False}
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"] = {"thinking": False}
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
+ )