local-openai2anthropic 0.1.0__py3-none-any.whl → 0.3.6__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,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Streaming response handling for local_openai2anthropic."""
3
+
4
+ from .handler import _convert_result_to_stream, _stream_response
5
+
6
+ __all__ = ["_stream_response", "_convert_result_to_stream"]
@@ -0,0 +1,444 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Streaming response handlers."""
3
+
4
+ import json
5
+ import logging
6
+ import time
7
+ from typing import Any, AsyncGenerator
8
+
9
+ import httpx
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from local_openai2anthropic.protocol import AnthropicError, AnthropicErrorResponse
13
+ from local_openai2anthropic.utils.tokens import (
14
+ _chunk_text,
15
+ _count_tokens,
16
+ _estimate_input_tokens,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def _stream_response(
23
+ client: httpx.AsyncClient,
24
+ url: str,
25
+ headers: dict,
26
+ json_data: dict,
27
+ model: str,
28
+ ) -> AsyncGenerator[str, None]:
29
+ """
30
+ Stream response from OpenAI and convert to Anthropic format.
31
+ """
32
+ try:
33
+ async with client.stream(
34
+ "POST", url, headers=headers, json=json_data
35
+ ) as response:
36
+ if response.status_code != 200:
37
+ error_body = await response.aread()
38
+ error_text = error_body.decode("utf-8", errors="replace").strip()
39
+ try:
40
+ error_json = json.loads(error_text) if error_text else {}
41
+ error_msg = error_json.get("error", {}).get("message") or error_text
42
+ except json.JSONDecodeError:
43
+ error_msg = error_text
44
+ if not error_msg:
45
+ error_msg = (
46
+ response.reason_phrase
47
+ or f"Upstream API error ({response.status_code})"
48
+ )
49
+
50
+ error_event = AnthropicErrorResponse(
51
+ error=AnthropicError(type="api_error", message=error_msg)
52
+ )
53
+ yield f"event: error\ndata: {error_event.model_dump_json()}\n\n"
54
+ yield "data: [DONE]\n\n"
55
+ return
56
+
57
+ # Process SSE stream
58
+ first_chunk = True
59
+ content_block_started = False
60
+ content_block_index = 0
61
+ current_block_type = None # 'thinking', 'text', or 'tool_use'
62
+ current_tool_call_index = None
63
+ tool_call_buffers: dict[int, str] = {}
64
+ finish_reason = None
65
+ input_tokens = _estimate_input_tokens(json_data)
66
+ output_tokens = 0
67
+ message_id = None
68
+ sent_message_delta = False
69
+ pending_text_prefix = ""
70
+
71
+ async for line in response.aiter_lines():
72
+ if not line.startswith("data: "):
73
+ continue
74
+
75
+ data = line[6:]
76
+ if data == "[DONE]":
77
+ if not sent_message_delta:
78
+ stop_reason_map = {
79
+ "stop": "end_turn",
80
+ "length": "max_tokens",
81
+ "tool_calls": "tool_use",
82
+ }
83
+ delta_event = {
84
+ "type": "message_delta",
85
+ "delta": {
86
+ "stop_reason": stop_reason_map.get(
87
+ finish_reason or "stop", "end_turn"
88
+ )
89
+ },
90
+ "usage": {
91
+ "input_tokens": input_tokens,
92
+ "output_tokens": output_tokens,
93
+ "cache_creation_input_tokens": None,
94
+ "cache_read_input_tokens": None,
95
+ },
96
+ }
97
+ logger.debug(
98
+ f"[Anthropic Stream Event] message_delta: {json.dumps(delta_event, ensure_ascii=False)}"
99
+ )
100
+ yield f"event: message_delta\ndata: {json.dumps(delta_event)}\n\n"
101
+ break
102
+
103
+ try:
104
+ chunk = json.loads(data)
105
+ logger.debug(
106
+ f"[OpenAI Stream Chunk] {json.dumps(chunk, ensure_ascii=False)}"
107
+ )
108
+ except json.JSONDecodeError:
109
+ continue
110
+
111
+ # First chunk: message_start
112
+ if first_chunk:
113
+ message_id = chunk.get("id", "")
114
+ usage = chunk.get("usage") or {}
115
+ input_tokens = usage.get("prompt_tokens", input_tokens)
116
+
117
+ start_event = {
118
+ "type": "message_start",
119
+ "message": {
120
+ "id": message_id,
121
+ "type": "message",
122
+ "role": "assistant",
123
+ "content": [],
124
+ "model": model,
125
+ "stop_reason": None,
126
+ "stop_sequence": None,
127
+ "usage": {
128
+ "input_tokens": input_tokens,
129
+ "output_tokens": 0,
130
+ "cache_creation_input_tokens": None,
131
+ "cache_read_input_tokens": None,
132
+ },
133
+ },
134
+ }
135
+ logger.debug(
136
+ f"[Anthropic Stream Event] message_start: {json.dumps(start_event, ensure_ascii=False)}"
137
+ )
138
+ yield f"event: message_start\ndata: {json.dumps(start_event)}\n\n"
139
+ first_chunk = False
140
+ continue
141
+
142
+ # Handle usage-only chunks
143
+ if not chunk.get("choices"):
144
+ usage = chunk.get("usage") or {}
145
+ if usage:
146
+ input_tokens = usage.get("prompt_tokens", input_tokens)
147
+ output_tokens = usage.get("completion_tokens", output_tokens)
148
+ if content_block_started:
149
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': content_block_index})}\n\n"
150
+ content_block_started = False
151
+
152
+ stop_reason_map = {
153
+ "stop": "end_turn",
154
+ "length": "max_tokens",
155
+ "tool_calls": "tool_use",
156
+ }
157
+ delta_event = {
158
+ "type": "message_delta",
159
+ "delta": {
160
+ "stop_reason": stop_reason_map.get(
161
+ finish_reason or "stop", "end_turn"
162
+ )
163
+ },
164
+ "usage": {
165
+ "input_tokens": usage.get(
166
+ "prompt_tokens", input_tokens
167
+ ),
168
+ "output_tokens": usage.get("completion_tokens", 0),
169
+ "cache_creation_input_tokens": None,
170
+ "cache_read_input_tokens": None,
171
+ },
172
+ }
173
+ logger.debug(
174
+ f"[Anthropic Stream Event] message_delta: {json.dumps(delta_event, ensure_ascii=False)}"
175
+ )
176
+ yield f"event: message_delta\ndata: {json.dumps(delta_event)}\n\n"
177
+ sent_message_delta = True
178
+ continue
179
+
180
+ choice = chunk["choices"][0]
181
+ delta = choice.get("delta", {})
182
+
183
+ # Track finish reason (but don't skip - content may also be present)
184
+ if choice.get("finish_reason"):
185
+ finish_reason = choice["finish_reason"]
186
+
187
+ # Handle reasoning content (thinking)
188
+ if delta.get("reasoning_content"):
189
+ reasoning = delta["reasoning_content"]
190
+ pending_text_prefix = ""
191
+ # Start thinking content block if not already started
192
+ if not content_block_started or current_block_type != "thinking":
193
+ # Close previous block if exists
194
+ if content_block_started:
195
+ stop_block = {
196
+ "type": "content_block_stop",
197
+ "index": content_block_index,
198
+ }
199
+ logger.debug(
200
+ f"[Anthropic Stream Event] content_block_stop ({current_block_type}): {json.dumps(stop_block, ensure_ascii=False)}"
201
+ )
202
+ yield f"event: content_block_stop\ndata: {json.dumps(stop_block)}\n\n"
203
+ content_block_index += 1
204
+ start_block = {
205
+ "type": "content_block_start",
206
+ "index": content_block_index,
207
+ "content_block": {
208
+ "type": "thinking",
209
+ "thinking": "",
210
+ "signature": "",
211
+ },
212
+ }
213
+ logger.debug(
214
+ f"[Anthropic Stream Event] content_block_start (thinking): {json.dumps(start_block, ensure_ascii=False)}"
215
+ )
216
+ yield f"event: content_block_start\ndata: {json.dumps(start_block)}\n\n"
217
+ content_block_started = True
218
+ current_block_type = "thinking"
219
+
220
+ for chunk in _chunk_text(reasoning):
221
+ delta_block = {
222
+ "type": "content_block_delta",
223
+ "index": content_block_index,
224
+ "delta": {"type": "thinking_delta", "thinking": chunk},
225
+ }
226
+ yield f"event: content_block_delta\ndata: {json.dumps(delta_block)}\n\n"
227
+ continue
228
+
229
+ # Handle content
230
+ if isinstance(delta.get("content"), str):
231
+ content_text = delta.get("content", "")
232
+ if not content_text:
233
+ continue
234
+ if content_text.strip() == "(no content)":
235
+ continue
236
+ if not content_block_started or current_block_type != "text":
237
+ if not content_text.strip():
238
+ pending_text_prefix += content_text
239
+ continue
240
+ # Close previous block if exists
241
+ if content_block_started:
242
+ stop_block = {
243
+ "type": "content_block_stop",
244
+ "index": content_block_index,
245
+ }
246
+ logger.debug(
247
+ f"[Anthropic Stream Event] content_block_stop ({current_block_type}): {json.dumps(stop_block, ensure_ascii=False)}"
248
+ )
249
+ yield f"event: content_block_stop\ndata: {json.dumps(stop_block)}\n\n"
250
+ content_block_index += 1
251
+ start_block = {
252
+ "type": "content_block_start",
253
+ "index": content_block_index,
254
+ "content_block": {"type": "text", "text": ""},
255
+ }
256
+ logger.debug(
257
+ f"[Anthropic Stream Event] content_block_start (text): {json.dumps(start_block, ensure_ascii=False)}"
258
+ )
259
+ yield f"event: content_block_start\ndata: {json.dumps(start_block)}\n\n"
260
+ content_block_started = True
261
+ current_block_type = "text"
262
+
263
+ if pending_text_prefix:
264
+ content_text = pending_text_prefix + content_text
265
+ pending_text_prefix = ""
266
+
267
+ output_tokens += _count_tokens(content_text)
268
+ delta_block = {
269
+ "type": "content_block_delta",
270
+ "index": content_block_index,
271
+ "delta": {"type": "text_delta", "text": content_text},
272
+ }
273
+ yield f"event: content_block_delta\ndata: {json.dumps(delta_block)}\n\n"
274
+
275
+ # Handle tool calls
276
+ if delta.get("tool_calls"):
277
+ pending_text_prefix = ""
278
+ for tool_call in delta["tool_calls"]:
279
+ tool_call_idx = tool_call.get("index", 0)
280
+
281
+ if tool_call.get("id"):
282
+ if content_block_started and (
283
+ current_block_type != "tool_use"
284
+ or current_tool_call_index != tool_call_idx
285
+ ):
286
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': content_block_index})}\n\n"
287
+ content_block_started = False
288
+ content_block_index += 1
289
+
290
+ if not content_block_started:
291
+ func = tool_call.get("function") or {}
292
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': content_block_index, 'content_block': {'type': 'tool_use', 'id': tool_call['id'], 'name': func.get('name', ''), 'input': {}}})}\n\n"
293
+ content_block_started = True
294
+ current_block_type = "tool_use"
295
+ current_tool_call_index = tool_call_idx
296
+ tool_call_buffers.setdefault(tool_call_idx, "")
297
+
298
+ if (tool_call.get("function") or {}).get("arguments"):
299
+ args = (tool_call.get("function") or {}).get(
300
+ "arguments", ""
301
+ )
302
+ if (
303
+ not content_block_started
304
+ or current_block_type != "tool_use"
305
+ or current_tool_call_index != tool_call_idx
306
+ ):
307
+ if content_block_started:
308
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': content_block_index})}\n\n"
309
+ content_block_index += 1
310
+ func = tool_call.get("function") or {}
311
+ tool_id = tool_call.get("id", "")
312
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': content_block_index, 'content_block': {'type': 'tool_use', 'id': tool_id, 'name': func.get('name', ''), 'input': {}}})}\n\n"
313
+ content_block_started = True
314
+ current_block_type = "tool_use"
315
+ current_tool_call_index = tool_call_idx
316
+ tool_call_buffers.setdefault(tool_call_idx, "")
317
+ tool_call_buffers[tool_call_idx] = (
318
+ tool_call_buffers.get(tool_call_idx, "") + args
319
+ )
320
+ yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': content_block_index, 'delta': {'type': 'input_json_delta', 'partial_json': args}})}\n\n"
321
+
322
+ # Close final content block
323
+ if content_block_started:
324
+ stop_block = {
325
+ "type": "content_block_stop",
326
+ "index": content_block_index,
327
+ }
328
+ logger.debug(
329
+ f"[Anthropic Stream Event] content_block_stop (final): {json.dumps(stop_block, ensure_ascii=False)}"
330
+ )
331
+ yield f"event: content_block_stop\ndata: {json.dumps(stop_block)}\n\n"
332
+
333
+ # Message stop
334
+ stop_event = {"type": "message_stop"}
335
+ logger.debug(
336
+ f"[Anthropic Stream Event] message_stop: {json.dumps(stop_event, ensure_ascii=False)}"
337
+ )
338
+ yield f"event: message_stop\ndata: {json.dumps(stop_event)}\n\n"
339
+
340
+ except Exception as e:
341
+ import traceback
342
+
343
+ error_msg = f"{str(e)}\n{traceback.format_exc()}"
344
+ logger.error(f"Stream error: {error_msg}")
345
+ error_event = AnthropicErrorResponse(
346
+ error=AnthropicError(type="internal_error", message=str(e))
347
+ )
348
+ yield f"event: error\ndata: {error_event.model_dump_json()}\n\n"
349
+
350
+
351
+ async def _convert_result_to_stream(
352
+ result: JSONResponse,
353
+ model: str,
354
+ ) -> AsyncGenerator[str, None]:
355
+ """Convert a JSONResponse to streaming SSE format."""
356
+ body = json.loads(bytes(result.body).decode("utf-8"))
357
+ message_id = body.get("id", f"msg_{int(time.time() * 1000)}")
358
+ content = body.get("content", [])
359
+ usage = body.get("usage", {})
360
+ stop_reason = body.get("stop_reason", "end_turn")
361
+
362
+ # Map stop_reason
363
+ stop_reason_map = {
364
+ "end_turn": "stop",
365
+ "max_tokens": "length",
366
+ "tool_use": "tool_calls",
367
+ }
368
+ openai_stop_reason = stop_reason_map.get(stop_reason, "stop")
369
+
370
+ # 1. message_start event
371
+ start_event = {
372
+ "type": "message_start",
373
+ "message": {
374
+ "id": message_id,
375
+ "type": "message",
376
+ "role": "assistant",
377
+ "content": [],
378
+ "model": model,
379
+ "stop_reason": None,
380
+ "stop_sequence": None,
381
+ "usage": {
382
+ "input_tokens": usage.get("input_tokens", 0),
383
+ "output_tokens": 0,
384
+ "cache_creation_input_tokens": usage.get("cache_creation_input_tokens"),
385
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens"),
386
+ },
387
+ },
388
+ }
389
+ yield f"event: message_start\ndata: {json.dumps(start_event)}\n\n"
390
+
391
+ # 2. Process content blocks
392
+ for i, block in enumerate(content):
393
+ block_type = block.get("type")
394
+
395
+ if block_type == "text":
396
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': i, 'content_block': {'type': 'text', 'text': ''}})}\n\n"
397
+ text = block.get("text", "")
398
+ yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': i, 'delta': {'type': 'text_delta', 'text': text}})}\n\n"
399
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': i})}\n\n"
400
+
401
+ elif block_type == "tool_use":
402
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': i, 'content_block': {'type': 'tool_use', 'id': block.get('id', ''), 'name': block.get('name', ''), 'input': block.get('input', {})}})}\n\n"
403
+ tool_input = block.get("input", {})
404
+ if tool_input:
405
+ input_json = json.dumps(tool_input, ensure_ascii=False)
406
+ yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': i, 'delta': {'type': 'input_json_delta', 'partial_json': input_json}})}\n\n"
407
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': i})}\n\n"
408
+
409
+ elif block_type == "server_tool_use":
410
+ # Preserve official Anthropic block type so clients can count server tool uses.
411
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': i, 'content_block': {'type': 'server_tool_use', 'id': block.get('id', ''), 'name': block.get('name', ''), 'input': block.get('input', {})}})}\n\n"
412
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': i})}\n\n"
413
+
414
+ elif block_type == "web_search_tool_result":
415
+ # Stream the tool result as its own content block.
416
+ tool_result_block = dict(block)
417
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': i, 'content_block': tool_result_block})}\n\n"
418
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': i})}\n\n"
419
+
420
+ elif block_type == "thinking":
421
+ # Handle thinking blocks (BetaThinkingBlock)
422
+ signature = block.get("signature", "")
423
+ yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': i, 'content_block': {'type': 'thinking', 'thinking': '', 'signature': signature}})}\n\n"
424
+ thinking_text = block.get("thinking", "")
425
+ if thinking_text:
426
+ yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': i, 'delta': {'type': 'thinking_delta', 'thinking': thinking_text}})}\n\n"
427
+ yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': i})}\n\n"
428
+
429
+ # 3. message_delta with final usage
430
+ delta_event = {
431
+ "type": "message_delta",
432
+ "delta": {"stop_reason": stop_reason},
433
+ "usage": {
434
+ "input_tokens": usage.get("input_tokens", 0),
435
+ "output_tokens": usage.get("output_tokens", 0),
436
+ "cache_creation_input_tokens": usage.get("cache_creation_input_tokens"),
437
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens"),
438
+ "server_tool_use": usage.get("server_tool_use"),
439
+ },
440
+ }
441
+ yield f"event: message_delta\ndata: {json.dumps(delta_event)}\n\n"
442
+
443
+ # 4. message_stop
444
+ yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n"
@@ -0,0 +1,14 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Server tool handling for local_openai2anthropic."""
3
+
4
+ from .handler import (
5
+ ServerToolHandler,
6
+ _add_tool_results_to_messages,
7
+ _handle_with_server_tools,
8
+ )
9
+
10
+ __all__ = [
11
+ "ServerToolHandler",
12
+ "_handle_with_server_tools",
13
+ "_add_tool_results_to_messages",
14
+ ]