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.
- LICENSE +202 -0
- NOTICE +18 -0
- THIRD-PARTY-NOTICES.md +22 -0
- TRADEMARKS.md +19 -0
- cloakapi/__init__.py +271 -0
- cloakapi/_disclosure/clk.txt +11 -0
- cloakapi/_disclosure/clk_message.txt +11 -0
- cloakapi/_disclosure/cloak_angle.txt +16 -0
- cloakapi/_disclosure/cloak_angle_message.txt +16 -0
- cloakapi/_http.py +443 -0
- cloakapi/_pretokenised.py +134 -0
- cloakapi/_relay.py +213 -0
- cloakapi/_release.py +30 -0
- cloakapi/_request_strings.py +1267 -0
- cloakapi/_ruleset/patterns.json +2238 -0
- cloakapi/_stream_detok.py +788 -0
- cloakapi/_version.py +31 -0
- cloakapi/async_client.py +704 -0
- cloakapi/attachments.py +176 -0
- cloakapi/cli.py +245 -0
- cloakapi/client.py +848 -0
- cloakapi/config.py +491 -0
- cloakapi/coverage.py +21 -0
- cloakapi/degraded.py +196 -0
- cloakapi/disclosure.py +334 -0
- cloakapi/engine.py +922 -0
- cloakapi/errors.py +304 -0
- cloakapi/notices.py +142 -0
- cloakapi/pii/__init__.py +4 -0
- cloakapi/pii/_checksum.py +510 -0
- cloakapi/pii/_ruleset.py +131 -0
- cloakapi/pii/patterns.py +1713 -0
- cloakapi/pii/rulefile.py +738 -0
- cloakapi/pii/tokenizer.py +537 -0
- cloakapi/posture.py +112 -0
- cloakapi/py.typed +1 -0
- cloakapi/receipts.py +605 -0
- cloakapi/resources/__init__.py +32 -0
- cloakapi/resources/admin.py +104 -0
- cloakapi/resources/buffer.py +54 -0
- cloakapi/resources/chat.py +293 -0
- cloakapi/resources/detect.py +115 -0
- cloakapi/resources/messages.py +260 -0
- cloakapi/resources/models.py +58 -0
- cloakapi/resources/receipts.py +252 -0
- cloakapi/resources/reconstruct.py +173 -0
- cloakapi/resources/tokens.py +47 -0
- cloakapi/review.py +112 -0
- cloakapi/streaming.py +446 -0
- cloakapi/surrogate_mode.py +308 -0
- cloakapi/types.py +284 -0
- cloakapi-0.5.1.dist-info/METADATA +441 -0
- cloakapi-0.5.1.dist-info/RECORD +61 -0
- cloakapi-0.5.1.dist-info/WHEEL +4 -0
- cloakapi-0.5.1.dist-info/entry_points.txt +2 -0
- cloakapi-0.5.1.dist-info/licenses/LICENSE +202 -0
- cloakapi-0.5.1.dist-info/licenses/NOTICE +18 -0
- licenses/Apache-2.0.txt +202 -0
- licenses/CC-BY-4.0.txt +104 -0
- licenses/MIT-Faker.txt +21 -0
- 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
|