cloakapi 0.5.1__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.
Files changed (61) hide show
  1. LICENSE +202 -0
  2. NOTICE +18 -0
  3. THIRD-PARTY-NOTICES.md +22 -0
  4. TRADEMARKS.md +19 -0
  5. cloakapi/__init__.py +271 -0
  6. cloakapi/_disclosure/clk.txt +11 -0
  7. cloakapi/_disclosure/clk_message.txt +11 -0
  8. cloakapi/_disclosure/cloak_angle.txt +16 -0
  9. cloakapi/_disclosure/cloak_angle_message.txt +16 -0
  10. cloakapi/_http.py +443 -0
  11. cloakapi/_pretokenised.py +134 -0
  12. cloakapi/_relay.py +213 -0
  13. cloakapi/_release.py +30 -0
  14. cloakapi/_request_strings.py +1267 -0
  15. cloakapi/_ruleset/patterns.json +2238 -0
  16. cloakapi/_stream_detok.py +788 -0
  17. cloakapi/_version.py +31 -0
  18. cloakapi/async_client.py +704 -0
  19. cloakapi/attachments.py +176 -0
  20. cloakapi/cli.py +245 -0
  21. cloakapi/client.py +848 -0
  22. cloakapi/config.py +491 -0
  23. cloakapi/coverage.py +21 -0
  24. cloakapi/degraded.py +196 -0
  25. cloakapi/disclosure.py +334 -0
  26. cloakapi/engine.py +922 -0
  27. cloakapi/errors.py +304 -0
  28. cloakapi/notices.py +142 -0
  29. cloakapi/pii/__init__.py +4 -0
  30. cloakapi/pii/_checksum.py +510 -0
  31. cloakapi/pii/_ruleset.py +131 -0
  32. cloakapi/pii/patterns.py +1713 -0
  33. cloakapi/pii/rulefile.py +738 -0
  34. cloakapi/pii/tokenizer.py +537 -0
  35. cloakapi/posture.py +112 -0
  36. cloakapi/py.typed +1 -0
  37. cloakapi/receipts.py +605 -0
  38. cloakapi/resources/__init__.py +32 -0
  39. cloakapi/resources/admin.py +104 -0
  40. cloakapi/resources/buffer.py +54 -0
  41. cloakapi/resources/chat.py +293 -0
  42. cloakapi/resources/detect.py +115 -0
  43. cloakapi/resources/messages.py +260 -0
  44. cloakapi/resources/models.py +58 -0
  45. cloakapi/resources/receipts.py +252 -0
  46. cloakapi/resources/reconstruct.py +173 -0
  47. cloakapi/resources/tokens.py +47 -0
  48. cloakapi/review.py +112 -0
  49. cloakapi/streaming.py +446 -0
  50. cloakapi/surrogate_mode.py +308 -0
  51. cloakapi/types.py +284 -0
  52. cloakapi-0.5.1.dist-info/METADATA +441 -0
  53. cloakapi-0.5.1.dist-info/RECORD +61 -0
  54. cloakapi-0.5.1.dist-info/WHEEL +4 -0
  55. cloakapi-0.5.1.dist-info/entry_points.txt +2 -0
  56. cloakapi-0.5.1.dist-info/licenses/LICENSE +202 -0
  57. cloakapi-0.5.1.dist-info/licenses/NOTICE +18 -0
  58. licenses/Apache-2.0.txt +202 -0
  59. licenses/CC-BY-4.0.txt +104 -0
  60. licenses/MIT-Faker.txt +21 -0
  61. licenses/MIT.txt +21 -0
@@ -0,0 +1,788 @@
1
+ """Boundary-safe streaming detokenisation — ONE implementation, four call sites.
2
+
3
+ WHY THIS MODULE EXISTS
4
+ ----------------------
5
+ Detokenising each SSE delta INDEPENDENTLY cannot see a replacement that
6
+ straddles a chunk boundary::
7
+
8
+ delta 1: "...mail emily.rivera@w"
9
+ delta 2: "estmarch.co.uk, ok?"
10
+
11
+ Neither delta contains the whole key, so neither is rewritten and the STAND-IN
12
+ reaches the caller. Under ``max`` that is at least visible (a stray
13
+ ``<<CLOAK:...>>`` looks obviously wrong); under the default ``balanced`` the
14
+ stand-in is a plausible value, so the caller silently reads a fake name, address
15
+ or email as though the model had produced it.
16
+
17
+ The SDK has FOUR streaming paths — Anthropic and OpenAI, each sync and async.
18
+ The defect was present in all four because each had its own copy of the
19
+ per-delta logic. Putting the sequencing here means a future fix cannot land in
20
+ two of the four and be believed to have landed in all of them; the only thing
21
+ the clients supply is how to read text out of an event and how to put it back.
22
+
23
+ THE END-OF-STREAM CONTRACT
24
+ --------------------------
25
+ :class:`~cloakapi.pii.tokenizer.StreamDetokenizer` withholds a short tail, so it
26
+ MUST be flushed or the last characters of the reply are silently truncated —
27
+ trading a corruption bug for a data-loss bug. The tail is released BEFORE the
28
+ first terminal event rather than after the loop, so a caller that stops reading
29
+ at ``message_stop`` / ``finish_reason`` cannot miss it.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ from collections.abc import AsyncIterator, Callable, Iterator
35
+ from typing import Any
36
+
37
+ # A "shape" is the small amount a caller must tell us about its event type:
38
+ # read(event) -> str | None (the text carried, or None)
39
+ # rewrite(event, text) -> event (same event, new text)
40
+ # is_terminal(event) -> bool (stream is ending with this event)
41
+ # make_tail(text, template) -> event (a synthetic event carrying the tail)
42
+ Shape = tuple[
43
+ Callable[[Any], "str | dict[int, str] | None"],
44
+ Callable[[Any, Any], Any],
45
+ Callable[[Any], bool],
46
+ Callable[[Any, Any], Any],
47
+ ]
48
+
49
+
50
+ def _choice_detokenizer(template: Any, streams: dict[int, Any]) -> Any:
51
+ """Use the supplied instance for the first choice and clone its map for others."""
52
+ if not streams:
53
+ return template
54
+ try:
55
+ return type(template)(getattr(template, "_map", {}))
56
+ except TypeError:
57
+ return type(template)()
58
+
59
+
60
+ def detok_stream(events: Iterator[Any], sd: Any, shape: Shape) -> Iterator[Any]:
61
+ """Synchronous boundary-safe pass over ``events``."""
62
+ read, rewrite, is_terminal, make_tail = shape
63
+ if getattr(read, "_cloak_openai_shape", False):
64
+ yield from _detok_openai_stream(events, sd)
65
+ return
66
+ if getattr(read, "_cloak_anthropic_shape", False):
67
+ yield from _detok_anthropic_stream(events, sd)
68
+ return
69
+ flushed = False
70
+ last: Any = None
71
+ tail: Any
72
+ per_choice: dict[int, Any] = {}
73
+ per_choice_flushed: set[int] = set()
74
+ multi_choice = False
75
+ try:
76
+ for event in events:
77
+ text = read(event)
78
+ if isinstance(text, dict):
79
+ multi_choice = True
80
+ last = event
81
+ output: dict[int, str] = {}
82
+ terminal_indices = {
83
+ choice.index
84
+ for choice in getattr(event, "choices", ())
85
+ if getattr(choice, "finish_reason", None) is not None
86
+ }
87
+ if is_terminal(event) and not terminal_indices:
88
+ tail = {
89
+ index: stream.flush()
90
+ for index, stream in per_choice.items()
91
+ if index not in per_choice_flushed
92
+ }
93
+ per_choice_flushed.update(tail)
94
+ tail = {index: value for index, value in tail.items() if value}
95
+ if tail:
96
+ yield make_tail(tail, event)
97
+ terminal_tails: dict[int, str] = {}
98
+ for index, part in text.items():
99
+ if index not in per_choice:
100
+ per_choice[index] = _choice_detokenizer(sd, per_choice)
101
+ stream = per_choice[index]
102
+ emitted = stream.feed(part) or ""
103
+ if index in terminal_indices and index not in per_choice_flushed:
104
+ tail = stream.flush() or ""
105
+ if not part and not emitted and tail:
106
+ terminal_tails[index] = tail
107
+ else:
108
+ emitted += tail
109
+ per_choice_flushed.add(index)
110
+ output[index] = emitted
111
+ for index in terminal_indices:
112
+ if index in per_choice and index not in per_choice_flushed:
113
+ output[index] = output.get(index, "") + (per_choice[index].flush() or "")
114
+ per_choice_flushed.add(index)
115
+ if terminal_tails:
116
+ yield make_tail(terminal_tails, event)
117
+ yield rewrite(event, output)
118
+ continue
119
+ # A finish chunk can carry the last text. Flushing before feed()
120
+ # leaves that suffix in the buffer after the one-shot flush.
121
+ if is_terminal(event) and not flushed and text:
122
+ emitted = sd.feed(text) or ""
123
+ tail = sd.flush() or ""
124
+ flushed = True
125
+ last = event
126
+ yield rewrite(event, emitted + tail)
127
+ continue
128
+ if is_terminal(event) and not flushed:
129
+ tail = sd.flush()
130
+ flushed = True
131
+ if tail:
132
+ yield make_tail(tail, event)
133
+ last = event
134
+ yield rewrite(event, sd.feed(text)) if text is not None else event
135
+ except Exception as exc:
136
+ if getattr(exc, "code", None) == "stream_truncated" and not flushed:
137
+ if multi_choice:
138
+ tails = {
139
+ index: stream.flush()
140
+ for index, stream in per_choice.items()
141
+ if index not in per_choice_flushed
142
+ }
143
+ per_choice_flushed.update(tails)
144
+ tails = {index: value for index, value in tails.items() if value}
145
+ flushed = True
146
+ if tails and last is not None:
147
+ yield make_tail(tails, last)
148
+ else:
149
+ tail = sd.flush()
150
+ flushed = True
151
+ if tail and last is not None:
152
+ yield make_tail(tail, last)
153
+ raise
154
+ finally:
155
+ close = getattr(events, "close", None)
156
+ if callable(close):
157
+ with contextlib.suppress(Exception):
158
+ close()
159
+ if multi_choice:
160
+ tails = {
161
+ index: stream.flush()
162
+ for index, stream in per_choice.items()
163
+ if index not in per_choice_flushed
164
+ }
165
+ tails = {index: value for index, value in tails.items() if value}
166
+ if tails and last is not None:
167
+ yield make_tail(tails, last)
168
+ elif not flushed:
169
+ tail = sd.flush()
170
+ if tail and last is not None:
171
+ yield make_tail(tail, last)
172
+
173
+
174
+ async def adetok_stream(events: AsyncIterator[Any], sd: Any, shape: Shape) -> AsyncIterator[Any]:
175
+ """Asynchronous twin of :func:`detok_stream`. Same sequencing, same rules."""
176
+ read, rewrite, is_terminal, make_tail = shape
177
+ if getattr(read, "_cloak_openai_shape", False):
178
+ openai_events = _adetok_openai_stream(events, sd)
179
+ try:
180
+ async for chunk in openai_events:
181
+ yield chunk
182
+ finally:
183
+ close = getattr(openai_events, "aclose", None)
184
+ if callable(close):
185
+ with contextlib.suppress(Exception):
186
+ await close()
187
+ return
188
+ if getattr(read, "_cloak_anthropic_shape", False):
189
+ anthropic_events = _adetok_anthropic_stream(events, sd)
190
+ try:
191
+ async for event in anthropic_events:
192
+ yield event
193
+ finally:
194
+ close = getattr(anthropic_events, "aclose", None)
195
+ if callable(close):
196
+ with contextlib.suppress(Exception):
197
+ await close()
198
+ return
199
+ flushed = False
200
+ last: Any = None
201
+ tail: Any
202
+ per_choice: dict[int, Any] = {}
203
+ per_choice_flushed: set[int] = set()
204
+ multi_choice = False
205
+ try:
206
+ async for event in events:
207
+ text = read(event)
208
+ if isinstance(text, dict):
209
+ multi_choice = True
210
+ last = event
211
+ output: dict[int, str] = {}
212
+ terminal_indices = {
213
+ choice.index
214
+ for choice in getattr(event, "choices", ())
215
+ if getattr(choice, "finish_reason", None) is not None
216
+ }
217
+ if is_terminal(event) and not terminal_indices:
218
+ tail = {
219
+ index: stream.flush()
220
+ for index, stream in per_choice.items()
221
+ if index not in per_choice_flushed
222
+ }
223
+ per_choice_flushed.update(tail)
224
+ tail = {index: value for index, value in tail.items() if value}
225
+ if tail:
226
+ yield make_tail(tail, event)
227
+ terminal_tails: dict[int, str] = {}
228
+ for index, part in text.items():
229
+ if index not in per_choice:
230
+ per_choice[index] = _choice_detokenizer(sd, per_choice)
231
+ stream = per_choice[index]
232
+ emitted = stream.feed(part) or ""
233
+ if index in terminal_indices and index not in per_choice_flushed:
234
+ tail = stream.flush() or ""
235
+ if not part and not emitted and tail:
236
+ terminal_tails[index] = tail
237
+ else:
238
+ emitted += tail
239
+ per_choice_flushed.add(index)
240
+ output[index] = emitted
241
+ for index in terminal_indices:
242
+ if index in per_choice and index not in per_choice_flushed:
243
+ output[index] = output.get(index, "") + (per_choice[index].flush() or "")
244
+ per_choice_flushed.add(index)
245
+ if terminal_tails:
246
+ yield make_tail(terminal_tails, event)
247
+ yield rewrite(event, output)
248
+ continue
249
+ # Same ordering as detok_stream: feed a non-empty terminal chunk
250
+ # before the one-shot flush, or its held suffix never leaves.
251
+ if is_terminal(event) and not flushed and text:
252
+ emitted = sd.feed(text) or ""
253
+ tail = sd.flush() or ""
254
+ flushed = True
255
+ last = event
256
+ yield rewrite(event, emitted + tail)
257
+ continue
258
+ if is_terminal(event) and not flushed:
259
+ tail = sd.flush()
260
+ flushed = True
261
+ if tail:
262
+ yield make_tail(tail, event)
263
+ last = event
264
+ yield rewrite(event, sd.feed(text)) if text is not None else event
265
+ except Exception as exc:
266
+ if getattr(exc, "code", None) == "stream_truncated" and not flushed:
267
+ if multi_choice:
268
+ tails = {
269
+ index: stream.flush()
270
+ for index, stream in per_choice.items()
271
+ if index not in per_choice_flushed
272
+ }
273
+ per_choice_flushed.update(tails)
274
+ tails = {index: value for index, value in tails.items() if value}
275
+ flushed = True
276
+ if tails and last is not None:
277
+ yield make_tail(tails, last)
278
+ else:
279
+ tail = sd.flush()
280
+ flushed = True
281
+ if tail and last is not None:
282
+ yield make_tail(tail, last)
283
+ raise
284
+ finally:
285
+ close = getattr(events, "aclose", None)
286
+ if callable(close):
287
+ with contextlib.suppress(Exception):
288
+ await close()
289
+ if multi_choice:
290
+ tails = {
291
+ index: stream.flush()
292
+ for index, stream in per_choice.items()
293
+ if index not in per_choice_flushed
294
+ }
295
+ tails = {index: value for index, value in tails.items() if value}
296
+ if tails and last is not None:
297
+ yield make_tail(tails, last)
298
+ elif not flushed:
299
+ tail = sd.flush()
300
+ if tail and last is not None:
301
+ yield make_tail(tail, last)
302
+
303
+
304
+ def anthropic_shape() -> Shape:
305
+ """Event shape for the Anthropic ``content_block_delta`` stream."""
306
+ from .types import ContentBlockDelta
307
+
308
+ def read(event: Any) -> str | None:
309
+ if isinstance(event, ContentBlockDelta):
310
+ d = event.delta
311
+ if isinstance(d, dict):
312
+ text = d.get("text")
313
+ if isinstance(text, str):
314
+ return text
315
+ return None
316
+
317
+ def rewrite(event: Any, text: str) -> Any:
318
+ return ContentBlockDelta(
319
+ type=event.type, index=event.index, delta={**event.delta, "text": text}
320
+ )
321
+
322
+ def is_terminal(event: Any) -> bool:
323
+ # Only events a caller stops on. message_start and content_block_start
324
+ # also have no text; treating them as terminal used the single flush
325
+ # before any delta, so the held tail never left.
326
+ from .types import ErrorEvent, MessageStop
327
+
328
+ return isinstance(event, (MessageStop, ErrorEvent))
329
+
330
+ def make_tail(text: str, template: Any) -> Any:
331
+ return ContentBlockDelta(
332
+ type="content_block_delta",
333
+ index=getattr(template, "index", 0) or 0,
334
+ delta={"type": "text_delta", "text": text},
335
+ )
336
+
337
+ # Text and input_json_delta are separate streams per content block. The
338
+ # generic single-buffer path would glue a held text tail onto tool JSON.
339
+ read._cloak_anthropic_shape = True # type: ignore[attr-defined]
340
+ return read, rewrite, is_terminal, make_tail
341
+
342
+
343
+ def openai_shape() -> Shape:
344
+ """Event shape for the OpenAI ``chat.completion.chunk`` stream."""
345
+ from .types import ChatCompletionChunk, ChunkDelta, ErrorEvent, StreamChoice
346
+
347
+ def read(chunk: Any) -> dict[int, str]:
348
+ if isinstance(chunk, ErrorEvent):
349
+ return {}
350
+ texts: dict[int, str] = {}
351
+ for choice in chunk.choices:
352
+ if isinstance(choice.delta.content, str):
353
+ texts[choice.index] = choice.delta.content
354
+ return texts
355
+
356
+ def rewrite(chunk: Any, text: Any) -> Any:
357
+ if isinstance(chunk, ErrorEvent):
358
+ return chunk
359
+ new_choices = []
360
+ for choice in chunk.choices:
361
+ if choice.index in text:
362
+ choice = choice.model_copy(
363
+ update={
364
+ "delta": choice.delta.model_copy(
365
+ update={"content": text[choice.index]}
366
+ )
367
+ }
368
+ )
369
+ new_choices.append(choice)
370
+ return chunk.model_copy(update={"choices": new_choices})
371
+
372
+ def is_terminal(chunk: Any) -> bool:
373
+ return isinstance(chunk, ErrorEvent) or any(c.finish_reason for c in chunk.choices)
374
+
375
+ def make_tail(text: Any, template: Any) -> Any:
376
+ if isinstance(template, ErrorEvent):
377
+ template = ChatCompletionChunk(
378
+ id="", object="chat.completion.chunk", created=0, model="", choices=[]
379
+ )
380
+ if isinstance(text, dict):
381
+ return ChatCompletionChunk(
382
+ id=template.id,
383
+ object=template.object,
384
+ created=template.created,
385
+ model=template.model,
386
+ choices=[
387
+ StreamChoice(
388
+ index=index,
389
+ delta=ChunkDelta(role=None, content=tail),
390
+ finish_reason=None,
391
+ )
392
+ for index, tail in sorted(text.items())
393
+ ],
394
+ usage=None,
395
+ )
396
+ return ChatCompletionChunk(
397
+ id=template.id,
398
+ object=template.object,
399
+ created=template.created,
400
+ model=template.model,
401
+ choices=[
402
+ StreamChoice(
403
+ index=0, delta=ChunkDelta(role=None, content=text), finish_reason=None
404
+ )
405
+ ],
406
+ usage=None,
407
+ )
408
+
409
+ # OpenAI chunks can carry several choices, and each choice is an independent
410
+ # output stream. Route the generic entry points through per-choice state
411
+ # instead of feeding choice 0 into one shared detokenizer.
412
+ read._cloak_openai_shape = True # type: ignore[attr-defined]
413
+ return read, rewrite, is_terminal, make_tail
414
+
415
+
416
+ def _openai_tail(text: str, template: Any, index: int) -> Any:
417
+ from .types import ChatCompletionChunk, ChunkDelta, StreamChoice
418
+
419
+ return ChatCompletionChunk(
420
+ id=template.id,
421
+ object=template.object,
422
+ created=template.created,
423
+ model=template.model,
424
+ choices=[
425
+ StreamChoice(
426
+ index=index, delta=ChunkDelta(role=None, content=text), finish_reason=None
427
+ )
428
+ ],
429
+ usage=None,
430
+ )
431
+
432
+
433
+ def _delta_field(delta: Any) -> tuple[str, str] | None:
434
+ """``(field, text)`` for an Anthropic delta that carries restorable text."""
435
+ if not isinstance(delta, dict):
436
+ return None
437
+ if isinstance(delta.get("text"), str):
438
+ return "text", delta["text"]
439
+ if delta.get("type") == "input_json_delta" and isinstance(delta.get("partial_json"), str):
440
+ return "partial_json", delta["partial_json"]
441
+ return None
442
+
443
+
444
+ def _anthropic_delta(event: Any, field: str, text: str) -> Any:
445
+ from .types import ContentBlockDelta
446
+
447
+ delta = dict(event.delta) if isinstance(event.delta, dict) else {}
448
+ delta[field] = text
449
+ if field == "partial_json":
450
+ delta["type"] = "input_json_delta"
451
+ elif "type" not in delta:
452
+ delta["type"] = "text_delta"
453
+ return ContentBlockDelta(type="content_block_delta", index=event.index, delta=delta)
454
+
455
+
456
+ def _anthropic_tail(index: int, field: str, text: str) -> Any:
457
+ from .types import ContentBlockDelta
458
+
459
+ if field == "partial_json":
460
+ delta = {"type": "input_json_delta", "partial_json": text}
461
+ else:
462
+ delta = {"type": "text_delta", "text": text}
463
+ return ContentBlockDelta(type="content_block_delta", index=index, delta=delta)
464
+
465
+
466
+ def _detok_anthropic_stream(events: Iterator[Any], sd: Any) -> Iterator[Any]:
467
+ """Restore text and ``input_json_delta`` per content block, across chunks."""
468
+ from .pii.tokenizer import StreamDetokenizer
469
+ from .types import ContentBlockDelta, ContentBlockStop, MessageStop
470
+
471
+ token_map = getattr(sd, "_map", {})
472
+ states: dict[int, Any] = {}
473
+ fields: dict[int, str] = {}
474
+ flushed: set[int] = set()
475
+
476
+ def stream_for(index: int) -> Any:
477
+ state = states.get(index)
478
+ if state is None:
479
+ state = sd if not states else StreamDetokenizer(token_map)
480
+ states[index] = state
481
+ return state
482
+
483
+ def flush_index(index: int) -> Any | None:
484
+ if index in flushed or index not in states:
485
+ flushed.add(index)
486
+ return None
487
+ tail = states[index].flush()
488
+ flushed.add(index)
489
+ if tail:
490
+ return _anthropic_tail(index, fields.get(index, "text"), tail)
491
+ return None
492
+
493
+ def flush_all() -> Iterator[Any]:
494
+ for index in list(states):
495
+ tail = flush_index(index)
496
+ if tail is not None:
497
+ yield tail
498
+
499
+ try:
500
+ for event in events:
501
+ if isinstance(event, ContentBlockStop):
502
+ tail = flush_index(event.index)
503
+ if tail is not None:
504
+ yield tail
505
+ yield event
506
+ continue
507
+ if isinstance(event, MessageStop):
508
+ yield from flush_all()
509
+ yield event
510
+ continue
511
+ if isinstance(event, ContentBlockDelta):
512
+ found = _delta_field(event.delta)
513
+ if found is not None:
514
+ field, text = found
515
+ fields[event.index] = field
516
+ emitted = stream_for(event.index).feed(text)
517
+ yield _anthropic_delta(event, field, emitted)
518
+ continue
519
+ yield event
520
+ except Exception as exc:
521
+ if getattr(exc, "code", None) == "stream_truncated":
522
+ yield from flush_all()
523
+ raise
524
+ finally:
525
+ close = getattr(events, "close", None)
526
+ if callable(close):
527
+ with contextlib.suppress(Exception):
528
+ close()
529
+ yield from flush_all()
530
+
531
+
532
+ async def _adetok_anthropic_stream(events: AsyncIterator[Any], sd: Any) -> AsyncIterator[Any]:
533
+ """Async twin of :func:`_detok_anthropic_stream`."""
534
+ from .pii.tokenizer import StreamDetokenizer
535
+ from .types import ContentBlockDelta, ContentBlockStop, MessageStop
536
+
537
+ token_map = getattr(sd, "_map", {})
538
+ states: dict[int, Any] = {}
539
+ fields: dict[int, str] = {}
540
+ flushed: set[int] = set()
541
+
542
+ def stream_for(index: int) -> Any:
543
+ state = states.get(index)
544
+ if state is None:
545
+ state = sd if not states else StreamDetokenizer(token_map)
546
+ states[index] = state
547
+ return state
548
+
549
+ def take_tail(index: int) -> Any | None:
550
+ if index in flushed or index not in states:
551
+ flushed.add(index)
552
+ return None
553
+ tail = states[index].flush()
554
+ flushed.add(index)
555
+ if tail:
556
+ return _anthropic_tail(index, fields.get(index, "text"), tail)
557
+ return None
558
+
559
+ try:
560
+ async for event in events:
561
+ if isinstance(event, ContentBlockStop):
562
+ tail = take_tail(event.index)
563
+ if tail is not None:
564
+ yield tail
565
+ yield event
566
+ continue
567
+ if isinstance(event, MessageStop):
568
+ for index in list(states):
569
+ tail = take_tail(index)
570
+ if tail is not None:
571
+ yield tail
572
+ yield event
573
+ continue
574
+ if isinstance(event, ContentBlockDelta):
575
+ found = _delta_field(event.delta)
576
+ if found is not None:
577
+ field, text = found
578
+ fields[event.index] = field
579
+ emitted = stream_for(event.index).feed(text)
580
+ yield _anthropic_delta(event, field, emitted)
581
+ continue
582
+ yield event
583
+ except Exception as exc:
584
+ if getattr(exc, "code", None) == "stream_truncated":
585
+ for index in list(states):
586
+ tail = take_tail(index)
587
+ if tail is not None:
588
+ yield tail
589
+ raise
590
+ finally:
591
+ close = getattr(events, "aclose", None)
592
+ if callable(close):
593
+ with contextlib.suppress(Exception):
594
+ await close()
595
+ for index in list(states):
596
+ tail = take_tail(index)
597
+ if tail is not None:
598
+ yield tail
599
+
600
+
601
+ def _restore_openai_tool_args(
602
+ delta: Any,
603
+ choice_index: int,
604
+ states: dict[tuple[int, int], Any],
605
+ token_map: dict[str, str],
606
+ *,
607
+ finishing: bool,
608
+ flushed: set[tuple[int, int]],
609
+ ) -> Any:
610
+ """Restore ``delta.tool_calls[].function.arguments``, including a held tail."""
611
+ from .pii.tokenizer import StreamDetokenizer
612
+
613
+ calls = getattr(delta, "tool_calls", None)
614
+ new_calls: list[Any] = []
615
+ changed = False
616
+ seen: set[tuple[int, int]] = set()
617
+ if isinstance(calls, list):
618
+ for call in calls:
619
+ if not isinstance(call, dict):
620
+ new_calls.append(call)
621
+ continue
622
+ tool_index = call.get("index", 0)
623
+ if not isinstance(tool_index, int):
624
+ tool_index = 0
625
+ key = (choice_index, tool_index)
626
+ seen.add(key)
627
+ fn = call.get("function")
628
+ arguments = fn.get("arguments") if isinstance(fn, dict) else None
629
+ if not isinstance(arguments, str):
630
+ new_calls.append(call)
631
+ continue
632
+ state = states.get(key)
633
+ if state is None:
634
+ state = StreamDetokenizer(token_map)
635
+ states[key] = state
636
+ emitted = state.feed(arguments) or ""
637
+ if finishing and key not in flushed:
638
+ emitted += state.flush() or ""
639
+ flushed.add(key)
640
+ new_calls.append({**call, "function": {**fn, "arguments": emitted}})
641
+ changed = True
642
+ if finishing:
643
+ for key, state in list(states.items()):
644
+ if key[0] != choice_index or key in flushed or key in seen:
645
+ continue
646
+ tail = state.flush() or ""
647
+ flushed.add(key)
648
+ if tail:
649
+ new_calls.append({"index": key[1], "function": {"arguments": tail}})
650
+ changed = True
651
+ if not changed:
652
+ return delta
653
+ return delta.model_copy(update={"tool_calls": new_calls})
654
+
655
+
656
+ def _detok_openai_stream(events: Iterator[Any], sd: Any) -> Iterator[Any]:
657
+ """Detokenize each OpenAI choice with its own boundary buffer."""
658
+ from .pii.tokenizer import StreamDetokenizer
659
+ from .types import ErrorEvent
660
+
661
+ token_map = getattr(sd, "_map", {})
662
+ states: dict[int, StreamDetokenizer] = {}
663
+ arg_states: dict[tuple[int, int], Any] = {}
664
+ arg_flushed: set[tuple[int, int]] = set()
665
+ last: dict[int, Any] = {}
666
+ flushed: set[int] = set()
667
+
668
+ def flush_pending(template: Any) -> Iterator[Any]:
669
+ for index, state in list(states.items()):
670
+ if index in flushed:
671
+ continue
672
+ tail = state.flush()
673
+ flushed.add(index)
674
+ if tail and index in last:
675
+ yield _openai_tail(tail, last.get(index, template), index)
676
+
677
+ try:
678
+ for chunk in events:
679
+ if isinstance(chunk, ErrorEvent):
680
+ yield from flush_pending(chunk)
681
+ yield chunk
682
+ continue
683
+ new_choices = []
684
+ for choice in chunk.choices:
685
+ index = choice.index
686
+ last[index] = chunk
687
+ state = states.get(index)
688
+ if state is None:
689
+ state = sd if not states else StreamDetokenizer(token_map)
690
+ states[index] = state
691
+ content = choice.delta.content
692
+ if isinstance(content, str):
693
+ content = state.feed(content)
694
+ if choice.finish_reason and index not in flushed:
695
+ tail = state.flush()
696
+ flushed.add(index)
697
+ if tail and not content:
698
+ yield _openai_tail(tail, chunk, index)
699
+ else:
700
+ content = (content or "") + tail
701
+ delta = choice.delta
702
+ if isinstance(choice.delta.content, str) or content:
703
+ delta = choice.delta.model_copy(update={"content": content})
704
+ delta = _restore_openai_tool_args(
705
+ delta, index, arg_states, token_map,
706
+ finishing=bool(choice.finish_reason), flushed=arg_flushed,
707
+ )
708
+ new_choices.append(choice.model_copy(update={"delta": delta}))
709
+ yield chunk.model_copy(update={"choices": new_choices})
710
+ except Exception as exc:
711
+ if getattr(exc, "code", None) == "stream_truncated":
712
+ yield from flush_pending(next(iter(last.values()), None))
713
+ raise
714
+ finally:
715
+ close = getattr(events, "close", None)
716
+ if callable(close):
717
+ with contextlib.suppress(Exception):
718
+ close()
719
+ yield from flush_pending(next(iter(last.values()), None))
720
+
721
+
722
+ async def _adetok_openai_stream(events: AsyncIterator[Any], sd: Any) -> AsyncIterator[Any]:
723
+ """Async per-choice twin of :func:`_detok_openai_stream`."""
724
+ from .pii.tokenizer import StreamDetokenizer
725
+ from .types import ErrorEvent
726
+
727
+ token_map = getattr(sd, "_map", {})
728
+ states: dict[int, StreamDetokenizer] = {}
729
+ arg_states: dict[tuple[int, int], Any] = {}
730
+ arg_flushed: set[tuple[int, int]] = set()
731
+ last: dict[int, Any] = {}
732
+ flushed: set[int] = set()
733
+
734
+ async def flush_pending(template: Any) -> AsyncIterator[Any]:
735
+ for index, state in list(states.items()):
736
+ if index in flushed:
737
+ continue
738
+ tail = state.flush()
739
+ flushed.add(index)
740
+ if tail and index in last:
741
+ yield _openai_tail(tail, last.get(index, template), index)
742
+
743
+ try:
744
+ async for chunk in events:
745
+ if isinstance(chunk, ErrorEvent):
746
+ async for tail in flush_pending(chunk):
747
+ yield tail
748
+ yield chunk
749
+ continue
750
+ new_choices = []
751
+ for choice in chunk.choices:
752
+ index = choice.index
753
+ last[index] = chunk
754
+ state = states.get(index)
755
+ if state is None:
756
+ state = sd if not states else StreamDetokenizer(token_map)
757
+ states[index] = state
758
+ content = choice.delta.content
759
+ if isinstance(content, str):
760
+ content = state.feed(content)
761
+ if choice.finish_reason and index not in flushed:
762
+ tail = state.flush()
763
+ flushed.add(index)
764
+ if tail and not content:
765
+ yield _openai_tail(tail, chunk, index)
766
+ else:
767
+ content = (content or "") + tail
768
+ delta = choice.delta
769
+ if isinstance(choice.delta.content, str) or content:
770
+ delta = choice.delta.model_copy(update={"content": content})
771
+ delta = _restore_openai_tool_args(
772
+ delta, index, arg_states, token_map,
773
+ finishing=bool(choice.finish_reason), flushed=arg_flushed,
774
+ )
775
+ new_choices.append(choice.model_copy(update={"delta": delta}))
776
+ yield chunk.model_copy(update={"choices": new_choices})
777
+ except Exception as exc:
778
+ if getattr(exc, "code", None) == "stream_truncated":
779
+ async for tail in flush_pending(next(iter(last.values()), None)):
780
+ yield tail
781
+ raise
782
+ finally:
783
+ close = getattr(events, "aclose", None)
784
+ if callable(close):
785
+ with contextlib.suppress(Exception):
786
+ await close()
787
+ async for tail in flush_pending(next(iter(last.values()), None)):
788
+ yield tail