cloakwall 0.1.0__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.
cloakwall/__init__.py ADDED
File without changes
cloakwall/audit.py ADDED
@@ -0,0 +1,231 @@
1
+ """
2
+ cloakwall.audit — tamper-evident local audit trail, plus SIEM export.
3
+
4
+ What a compliance officer asks for after an incident is not "were you
5
+ redacting" but "prove what happened on 14 March and prove the record has not
6
+ been edited since". A plain log file cannot answer the second half.
7
+
8
+ Every entry commits to the SHA-256 of the entry before it, so rewriting any
9
+ line breaks the chain from that point on and `verify()` names the line.
10
+
11
+ A bare hash chain does not survive truncation, though: a prefix of a valid
12
+ chain is itself a valid chain, so dropping the last N entries verifies
13
+ clean. Two things close that gap.
14
+
15
+ 1. Each entry carries a monotonic sequence number, so gaps in the middle
16
+ are caught even when the hashes are recomputed.
17
+
18
+ 2. The head (sequence number plus hash) is mirrored to a separate anchor
19
+ file after every append, and optionally pushed to your SIEM. Verifying
20
+ against the anchor detects truncation, because the anchor still knows
21
+ how many entries there should be.
22
+
23
+ The anchor is only as good as its separation from the log. On the same
24
+ filesystem it stops accidental truncation and a careless attacker; pushed to
25
+ a SIEM under different access control, it stops a determined one. That trust
26
+ boundary is the operator's to choose, so both are supported and neither is
27
+ claimed to be the other.
28
+
29
+ The log never stores the values that were redacted. It stores what kind of
30
+ entity was found and how many -- enough to prove the control was working,
31
+ without becoming a second copy of the data you were protecting.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import socket
40
+ import threading
41
+ import time
42
+ import urllib.error
43
+ import urllib.request
44
+ from datetime import datetime, timezone
45
+
46
+ GENESIS = "0" * 64
47
+
48
+
49
+ class AuditLog:
50
+ def __init__(self, path: str = "./cloakwall-audit.log"):
51
+ self.path = path
52
+ self._lock = threading.Lock() # hooks run concurrently under uvicorn
53
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
54
+
55
+ @property
56
+ def anchor_path(self) -> str:
57
+ return self.path + ".head"
58
+
59
+ def _last(self) -> tuple[int, str]:
60
+ """Sequence number and hash of the final entry."""
61
+ if not os.path.exists(self.path):
62
+ return 0, GENESIS
63
+ # Read the tail rather than the whole file: this log grows fast and
64
+ # loading it on every request would be O(n) per request.
65
+ with open(self.path, "rb") as fh:
66
+ try:
67
+ fh.seek(-4096, os.SEEK_END)
68
+ except OSError:
69
+ fh.seek(0)
70
+ tail = fh.read().decode("utf-8", "replace").splitlines()
71
+ for line in reversed(tail):
72
+ if line.strip():
73
+ try:
74
+ e = json.loads(line)
75
+ return int(e.get("seq", 0)), e["hash"]
76
+ except Exception:
77
+ continue
78
+ return 0, GENESIS
79
+
80
+ def _write_anchor(self, seq: int, head: str) -> None:
81
+ """Written atomically: a torn anchor would be worse than none."""
82
+ tmp = self.anchor_path + ".tmp"
83
+ with open(tmp, "w") as fh:
84
+ json.dump({"seq": seq, "hash": head,
85
+ "ts": datetime.now(timezone.utc).isoformat()}, fh)
86
+ fh.flush()
87
+ os.fsync(fh.fileno())
88
+ os.replace(tmp, self.anchor_path)
89
+
90
+ def read_anchor(self) -> dict | None:
91
+ try:
92
+ with open(self.anchor_path) as fh:
93
+ return json.load(fh)
94
+ except (OSError, ValueError):
95
+ return None
96
+
97
+ def append(self, event: str, **fields) -> dict:
98
+ with self._lock:
99
+ prev_seq, prev = self._last()
100
+ entry = {
101
+ "ts": datetime.now(timezone.utc).isoformat(),
102
+ "seq": prev_seq + 1,
103
+ "event": event,
104
+ "host": socket.gethostname(),
105
+ "prev": prev,
106
+ **fields,
107
+ }
108
+ body = json.dumps(entry, sort_keys=True)
109
+ entry["hash"] = hashlib.sha256((prev + body).encode()).hexdigest()
110
+ with open(self.path, "a") as fh:
111
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
112
+ fh.flush()
113
+ os.fsync(fh.fileno()) # survive a pod kill mid-write
114
+ self._write_anchor(entry["seq"], entry["hash"])
115
+ return entry
116
+
117
+ def verify(self, anchor: dict | None = None) -> tuple[bool, str]:
118
+ """Verify the chain, and if an anchor is available, verify the log has
119
+ not been truncated. Pass an anchor retrieved from your SIEM to check
120
+ against a copy the log's own filesystem cannot reach."""
121
+ if not os.path.exists(self.path):
122
+ return True, "no audit log yet"
123
+
124
+ prev = GENESIS
125
+ expected_seq = 0
126
+ last_hash = GENESIS
127
+ with open(self.path) as fh:
128
+ for n, line in enumerate(fh, 1):
129
+ if not line.strip():
130
+ continue
131
+ e = json.loads(line)
132
+ claimed = e.pop("hash", None)
133
+ if e.get("prev") != prev:
134
+ return False, f"line {n}: chain break"
135
+ expected_seq += 1
136
+ if int(e.get("seq", -1)) != expected_seq:
137
+ return False, (f"line {n}: sequence gap "
138
+ f"(expected {expected_seq}, found {e.get('seq')})")
139
+ recomputed = hashlib.sha256(
140
+ (prev + json.dumps(e, sort_keys=True)).encode()).hexdigest()
141
+ if recomputed != claimed:
142
+ return False, f"line {n}: entry altered after writing"
143
+ prev = last_hash = claimed
144
+
145
+ a = anchor if anchor is not None else self.read_anchor()
146
+ if a:
147
+ if expected_seq < int(a.get("seq", 0)):
148
+ return False, (f"truncated: anchor records {a['seq']} entries, "
149
+ f"log holds {expected_seq}")
150
+ if expected_seq == int(a.get("seq", 0)) and last_hash != a.get("hash"):
151
+ return False, "head does not match anchor"
152
+ return True, f"chain intact, {expected_seq} entries, anchor matches"
153
+ return True, f"chain intact, {expected_seq} entries (no anchor to check truncation)"
154
+
155
+
156
+ # ------------------------------------------------------------------ #
157
+ # SIEM export #
158
+ # #
159
+ # Fire-and-forget on a worker thread. An audit sink that is slow or #
160
+ # down must never add latency to, or fail, an LLM request -- the log #
161
+ # on disk is the system of record and the SIEM is a copy. #
162
+ # ------------------------------------------------------------------ #
163
+
164
+ class SIEMExporter:
165
+ def __init__(self, kind: str | None = None, url: str = "", token: str = "",
166
+ timeout: float = 3.0, verify_tls: bool = True):
167
+ self.kind = (kind or "").lower()
168
+ self.url = url
169
+ self.token = token
170
+ self.timeout = timeout
171
+ self.verify_tls = verify_tls
172
+ self.failures = 0
173
+
174
+ @classmethod
175
+ def from_env(cls) -> "SIEMExporter":
176
+ return cls(
177
+ kind=os.environ.get("CLOAKWALL_SIEM", ""),
178
+ url=os.environ.get("CLOAKWALL_SIEM_URL", ""),
179
+ token=os.environ.get("CLOAKWALL_SIEM_TOKEN", ""),
180
+ )
181
+
182
+ @property
183
+ def enabled(self) -> bool:
184
+ return bool(self.kind and self.url and self.token)
185
+
186
+ def _payload(self, entry: dict) -> tuple[bytes, dict]:
187
+ if self.kind == "splunk":
188
+ body = {
189
+ "time": time.time(),
190
+ "host": entry.get("host"),
191
+ "source": "cloakwall",
192
+ "sourcetype": "_json",
193
+ "event": entry,
194
+ }
195
+ return json.dumps(body).encode(), {
196
+ "Authorization": f"Splunk {self.token}",
197
+ "Content-Type": "application/json",
198
+ }
199
+ if self.kind == "datadog":
200
+ body = [{
201
+ "ddsource": "cloakwall",
202
+ "service": "cloakwall",
203
+ "hostname": entry.get("host"),
204
+ "message": json.dumps(entry),
205
+ }]
206
+ return json.dumps(body).encode(), {
207
+ "DD-API-KEY": self.token,
208
+ "Content-Type": "application/json",
209
+ }
210
+ # elasticsearch and anything else that accepts a bare JSON document
211
+ return json.dumps(entry).encode(), {
212
+ "Authorization": f"Bearer {self.token}",
213
+ "Content-Type": "application/json",
214
+ }
215
+
216
+ def _post(self, entry: dict) -> None:
217
+ try:
218
+ data, headers = self._payload(entry)
219
+ req = urllib.request.Request(self.url, data=data, headers=headers)
220
+ ctx = None
221
+ if not self.verify_tls:
222
+ import ssl
223
+ ctx = ssl._create_unverified_context() # self-signed on-prem SIEM
224
+ urllib.request.urlopen(req, timeout=self.timeout, context=ctx).read()
225
+ except (urllib.error.URLError, OSError, ValueError):
226
+ self.failures += 1 # counted and surfaced in /healthz, never raised
227
+
228
+ def send(self, entry: dict) -> None:
229
+ if not self.enabled:
230
+ return
231
+ threading.Thread(target=self._post, args=(entry,), daemon=True).start()
cloakwall/guardrail.py ADDED
@@ -0,0 +1,392 @@
1
+ """
2
+ cloakwall.guardrail — the LiteLLM adapter.
3
+
4
+ LiteLLM's OSS guardrail framework lets you register a CustomGuardrail without
5
+ an enterprise licence. This class implements the hooks that matter:
6
+
7
+ async_pre_call_hook runs before the request leaves. This is where
8
+ redaction has to happen -- LiteLLM documents
9
+ that pre_call is the mode to use when you
10
+ modify content, because during_call runs in
11
+ parallel with the LLM call and your edits may
12
+ not land before the request goes out.
13
+ Covers messages, the top-level system field,
14
+ prompt (completions, image generation) and
15
+ input (embeddings, Responses API).
16
+
17
+ async_post_call_success_hook runs on a non-streamed response. Always
18
+ audited; redacted too when redact_output is
19
+ on, so PII the model generates is masked
20
+ before the caller sees it.
21
+
22
+ async_post_call_streaming_iterator_hook
23
+ runs on a streamed response, chunk by chunk.
24
+ With redact_output on, text and tool-call
25
+ arguments go through a StreamRedactor so an
26
+ entity split across chunks is still caught.
27
+ (LiteLLM runs the success hook on streams
28
+ only after delivery, too late to redact.)
29
+
30
+ Config (litellm config.yaml):
31
+
32
+ guardrails:
33
+ - guardrail_name: cloakwall
34
+ litellm_params:
35
+ guardrail: cloakwall.guardrail.Cloakwall
36
+ mode: [pre_call, post_call] # post_call only needed for redact_output
37
+ redaction_mode: mask # mask | hash | partial
38
+ redact_output: true # also mask PII in model responses
39
+ audit_path: /var/log/cloakwall/audit.log
40
+ fail_closed: true
41
+
42
+ Everything runs in-process. No sidecar, no external endpoint, no model
43
+ download -- which is the point: it works unchanged in an air-gapped cluster.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import copy
49
+ import os
50
+ from typing import Any, AsyncGenerator, Optional, Union
51
+
52
+ from .audit import AuditLog, SIEMExporter
53
+ from .redact import Redaction, Redactor, StreamRedactor
54
+
55
+ try:
56
+ from litellm.integrations.custom_guardrail import CustomGuardrail
57
+ except ImportError: # allows the core to be imported and tested standalone
58
+ class CustomGuardrail: # type: ignore
59
+ def __init__(self, **kwargs): pass
60
+
61
+
62
+ # Request fields that carry text, across every endpoint the proxy forwards.
63
+ # Anything else in the request body (model, tools, temperature...) is config.
64
+ REQUEST_FIELDS = ("messages", "system", "prompt", "input")
65
+
66
+
67
+ class Cloakwall(CustomGuardrail):
68
+ def __init__(self,
69
+ redaction_mode: str = "mask",
70
+ entities: Optional[list] = None,
71
+ audit_path: str = "./cloakwall-audit.log",
72
+ fail_closed: bool = True,
73
+ anchor_every: int = 100,
74
+ block_on: Optional[list] = None,
75
+ redact_output: bool = False,
76
+ stream_holdback: int = 64,
77
+ **kwargs):
78
+ self.redactor = Redactor(
79
+ mode=redaction_mode,
80
+ entities=set(entities) if entities else None,
81
+ )
82
+ self.audit = AuditLog(audit_path)
83
+ self.siem = SIEMExporter.from_env()
84
+ # fail_closed decides what happens if redaction itself throws. In a
85
+ # regulated deployment the safe answer is to reject the request rather
86
+ # than forward text that may not have been scrubbed.
87
+ self.fail_closed = fail_closed
88
+ self.anchor_every = max(1, int(anchor_every))
89
+ # Entities that must never reach the model at all. Redaction is the
90
+ # default because it keeps the request useful, but some policies want
91
+ # the call rejected outright -- a card number in a tool-call argument
92
+ # is a bug upstream, and silently masking it hides the bug.
93
+ self.block_on = set(block_on or [])
94
+ # Off by default: masking output changes what callers receive, and an
95
+ # app that asks the model to draft an email to a known address would
96
+ # break. Turn it on where the model may echo or invent identifiers.
97
+ self.redact_output = bool(redact_output)
98
+ self.stream_holdback = int(stream_holdback)
99
+ self.blocked = 0
100
+ self.requests = 0
101
+ self.redacted = 0
102
+ self.responses_redacted = 0
103
+ super().__init__(**kwargs)
104
+
105
+ def _record(self, event: str, **fields) -> None:
106
+ entry = self.audit.append(event, **fields)
107
+ self.siem.send(entry)
108
+ # Mirror the chain head to the SIEM on a cadence. The SIEM sits under
109
+ # different access control from the log's filesystem, so an anchor
110
+ # held there is what makes end-truncation detectable by someone who
111
+ # can write to the pod. Every N entries rather than every entry, to
112
+ # keep the export volume sane.
113
+ if self.siem.enabled and entry["seq"] % self.anchor_every == 0:
114
+ self.siem.send({
115
+ "event": "cloakwall.anchor",
116
+ "seq": entry["seq"],
117
+ "hash": entry["hash"],
118
+ "ts": entry["ts"],
119
+ "host": entry["host"],
120
+ })
121
+
122
+ async def async_pre_call_hook(self, user_api_key_dict: Any, cache: Any,
123
+ data: dict, call_type: Any
124
+ ) -> Optional[Union[Exception, str, dict]]:
125
+ self.requests += 1
126
+ present = [f for f in REQUEST_FIELDS if data.get(f) is not None]
127
+ if not present:
128
+ return data
129
+
130
+ try:
131
+ cleaned, redactions = {}, []
132
+ for f in present:
133
+ if f == "messages" and isinstance(data[f], list):
134
+ cleaned[f], r = self.redactor.redact_messages(data[f])
135
+ else:
136
+ cleaned[f], r = self.redactor.redact_value(data[f])
137
+ redactions.extend(r)
138
+ except Exception as exc:
139
+ self._record("cloakwall.error", stage="pre_call", error=str(exc))
140
+ if self.fail_closed:
141
+ raise
142
+ return data
143
+
144
+ hits = {r.entity for r in redactions}
145
+ blocked = hits & self.block_on
146
+ if blocked:
147
+ self.blocked += 1
148
+ self._record("cloakwall.blocked",
149
+ model=data.get("model"),
150
+ key_alias=getattr(user_api_key_dict, "key_alias", None),
151
+ entities=_counts(redactions), blocked_on=sorted(blocked))
152
+ raise ValueError(
153
+ "Cloakwall blocked this request: "
154
+ f"{', '.join(sorted(blocked))} present in prompt or tool-call arguments")
155
+
156
+ if redactions:
157
+ self.redacted += 1
158
+ data.update(cleaned)
159
+ # Note what was found, never what it was.
160
+ self._record(
161
+ "cloakwall.redacted",
162
+ model=data.get("model"),
163
+ key_alias=getattr(user_api_key_dict, "key_alias", None),
164
+ team_id=getattr(user_api_key_dict, "team_id", None),
165
+ entities=_counts(redactions),
166
+ total=len(redactions),
167
+ mode=self.redactor.mode,
168
+ )
169
+ return data
170
+
171
+ async def async_post_call_success_hook(self, data: dict,
172
+ user_api_key_dict: Any,
173
+ response: Any) -> Any:
174
+ # A streamed response was already redacted chunk by chunk; LiteLLM
175
+ # hands this hook the assembled copy after delivery.
176
+ if self.redact_output and not data.get("stream"):
177
+ try:
178
+ redactions = self._redact_response(response)
179
+ except Exception as exc:
180
+ self._record("cloakwall.error", stage="post_call", error=str(exc))
181
+ if self.fail_closed:
182
+ raise
183
+ redactions = []
184
+ self._record_output(data, user_api_key_dict, redactions, stream=False)
185
+ self._record(
186
+ "cloakwall.response",
187
+ model=data.get("model"),
188
+ key_alias=getattr(user_api_key_dict, "key_alias", None),
189
+ finish_reason=_finish_reason(response),
190
+ )
191
+ return response
192
+
193
+ async def async_post_call_streaming_iterator_hook(
194
+ self, user_api_key_dict: Any, response: Any, request_data: dict
195
+ ) -> AsyncGenerator[Any, None]:
196
+ if not self.redact_output:
197
+ async for chunk in response:
198
+ yield chunk
199
+ return
200
+
201
+ # One StreamRedactor per text stream: each choice's content, and each
202
+ # tool call's arguments, arrive as separate interleaved fragments.
203
+ streams: dict[tuple, StreamRedactor] = {}
204
+ templates: dict[tuple, Any] = {}
205
+ redactions: list[Redaction] = []
206
+
207
+ def feed(key, chunk, text):
208
+ sr = streams.get(key)
209
+ if sr is None:
210
+ sr = streams[key] = StreamRedactor(self.redactor, self.stream_holdback)
211
+ templates[key] = chunk
212
+ r = sr.feed(text)
213
+ redactions.extend(r.redactions)
214
+ return r.text
215
+
216
+ def flush(choice_index=None):
217
+ # Held-back text goes out in a chunk of its own, cloned from the
218
+ # last chunk that carried that stream, before the finish chunk.
219
+ out = []
220
+ for key in list(streams):
221
+ if choice_index is not None and key[1] != choice_index:
222
+ continue
223
+ sr = streams.pop(key)
224
+ template = templates.pop(key)
225
+ if not sr.pending:
226
+ continue
227
+ r = sr.flush()
228
+ redactions.extend(r.redactions)
229
+ out.append(_flush_chunk(template, key, r.text))
230
+ return out
231
+
232
+ try:
233
+ async for chunk in response:
234
+ finishing = []
235
+ for ch in _get(chunk, "choices") or []:
236
+ idx = _get(ch, "index") or 0
237
+ delta = _get(ch, "delta")
238
+ if delta is not None:
239
+ text = _get(delta, "content")
240
+ if isinstance(text, str) and text:
241
+ _set(delta, "content", feed(("content", idx), chunk, text))
242
+ for tc in _get(delta, "tool_calls") or []:
243
+ fn = _get(tc, "function")
244
+ args = _get(fn, "arguments") if fn is not None else None
245
+ if isinstance(args, str) and args:
246
+ key = ("tool", idx, _get(tc, "index") or 0)
247
+ _set(fn, "arguments", feed(key, chunk, args))
248
+ if _get(ch, "finish_reason"):
249
+ finishing.append(idx)
250
+ for idx in finishing:
251
+ for extra in flush(idx):
252
+ yield extra
253
+ yield chunk
254
+ for extra in flush():
255
+ yield extra
256
+ except Exception as exc:
257
+ self._record("cloakwall.error", stage="stream", error=str(exc))
258
+ raise
259
+ finally:
260
+ self._record_output(request_data, user_api_key_dict, redactions, stream=True)
261
+
262
+ def _redact_response(self, response: Any) -> list[Redaction]:
263
+ """Mask PII in a completed response, in place. Handles LiteLLM's
264
+ ModelResponse objects and plain dicts: chat choices (content and
265
+ tool-call arguments), text-completion choices, and Anthropic-style
266
+ content blocks."""
267
+ reds: list[Redaction] = []
268
+ for ch in _get(response, "choices") or []:
269
+ msg = _get(ch, "message")
270
+ if msg is not None:
271
+ reds += self._redact_field(msg, "content")
272
+ for tc in _get(msg, "tool_calls") or []:
273
+ fn = _get(tc, "function")
274
+ if fn is not None:
275
+ reds += self._redact_field(fn, "arguments")
276
+ reds += self._redact_field(ch, "text")
277
+ content = _get(response, "content")
278
+ if isinstance(content, list):
279
+ for block in content:
280
+ kind = _get(block, "type")
281
+ if kind == "text":
282
+ reds += self._redact_field(block, "text")
283
+ elif kind == "tool_use":
284
+ new, r = self.redactor.redact_value(
285
+ {"type": "tool_use", "input": _get(block, "input")})
286
+ if r:
287
+ _set(block, "input", new["input"])
288
+ reds += r
289
+ return reds
290
+
291
+ def _redact_field(self, obj: Any, key: str) -> list[Redaction]:
292
+ value = _get(obj, key)
293
+ if value is None:
294
+ return []
295
+ new, r = self.redactor.redact_value(value)
296
+ if r:
297
+ _set(obj, key, new)
298
+ return r
299
+
300
+ def _record_output(self, data: dict, user_api_key_dict: Any,
301
+ redactions: list[Redaction], stream: bool) -> None:
302
+ if not redactions:
303
+ return
304
+ self.responses_redacted += 1
305
+ self._record(
306
+ "cloakwall.redacted_output",
307
+ model=(data or {}).get("model"),
308
+ key_alias=getattr(user_api_key_dict, "key_alias", None),
309
+ team_id=getattr(user_api_key_dict, "team_id", None),
310
+ entities=_counts(redactions),
311
+ total=len(redactions),
312
+ mode=self.redactor.mode,
313
+ stream=stream,
314
+ )
315
+
316
+ def stats(self) -> dict:
317
+ ok, msg = self.audit.verify()
318
+ anchor = self.audit.read_anchor()
319
+ return {
320
+ "requests": self.requests,
321
+ "requests_with_redactions": self.redacted,
322
+ "requests_blocked": self.blocked,
323
+ "responses_with_redactions": self.responses_redacted,
324
+ "redact_output": self.redact_output,
325
+ "block_on": sorted(self.block_on),
326
+ "redaction_mode": self.redactor.mode,
327
+ "entities_enabled": sorted(self.redactor.entities),
328
+ "audit_chain": msg,
329
+ "audit_intact": ok,
330
+ "audit_entries": (anchor or {}).get("seq", 0),
331
+ "anchor_every": self.anchor_every,
332
+ "siem": self.siem.kind if self.siem.enabled else "disabled",
333
+ "siem_failures": self.siem.failures,
334
+ }
335
+
336
+
337
+ def _counts(redactions: list[Redaction]) -> dict[str, int]:
338
+ out: dict[str, int] = {}
339
+ for r in redactions:
340
+ out[r.entity] = out.get(r.entity, 0) + 1
341
+ return out
342
+
343
+
344
+ def _get(obj: Any, key: str) -> Any:
345
+ """Field access that works on LiteLLM's response objects and on dicts."""
346
+ if isinstance(obj, dict):
347
+ return obj.get(key)
348
+ return getattr(obj, key, None)
349
+
350
+
351
+ def _set(obj: Any, key: str, value: Any) -> None:
352
+ if isinstance(obj, dict):
353
+ obj[key] = value
354
+ else:
355
+ setattr(obj, key, value)
356
+
357
+
358
+ def _flush_chunk(template: Any, key: tuple, text: str) -> Any:
359
+ """A copy of `template` carrying only the flushed text for one stream:
360
+ a single choice, no finish_reason, and for tool calls only the arguments
361
+ (id and name were already sent; repeating them makes clients that
362
+ concatenate deltas double the function name)."""
363
+ chunk = copy.deepcopy(template)
364
+ choice_index = key[1]
365
+ choices = [c for c in (_get(chunk, "choices") or [])
366
+ if (_get(c, "index") or 0) == choice_index]
367
+ _set(chunk, "choices", choices[:1])
368
+ for ch in choices[:1]:
369
+ _set(ch, "finish_reason", None)
370
+ delta = _get(ch, "delta")
371
+ if key[0] == "content":
372
+ _set(delta, "content", text)
373
+ _set(delta, "tool_calls", None)
374
+ else:
375
+ tool_index = key[2]
376
+ calls = [tc for tc in (_get(delta, "tool_calls") or [])
377
+ if (_get(tc, "index") or 0) == tool_index]
378
+ for tc in calls[:1]:
379
+ _set(tc, "id", None)
380
+ fn = _get(tc, "function")
381
+ _set(fn, "name", None)
382
+ _set(fn, "arguments", text)
383
+ _set(delta, "tool_calls", calls[:1])
384
+ _set(delta, "content", None)
385
+ return chunk
386
+
387
+
388
+ def _finish_reason(response: Any) -> Optional[str]:
389
+ try:
390
+ return response.choices[0].finish_reason
391
+ except Exception:
392
+ return None