kiwimatecoder 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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,820 @@
1
+ """Unified OpenAI and Anthropic streaming client with tool-calling support.
2
+
3
+ A single :class:`UnifiedClient` drives every provider in the registry. It supports
4
+ both OpenAI-compatible ``/chat/completions`` SSE streaming and native Anthropic
5
+ ``/messages`` SSE streaming. Chat-only providers (``supports_tools=False``) are
6
+ sent no tool schemas, and :func:`flatten_tool_messages` rewrites any tool
7
+ traffic in their history as plain text.
8
+
9
+ Streamed responses are surfaced as :class:`StreamEvent` objects. Tool calls
10
+ arrive as fragments indexed by position; :class:`ToolCallAssembler` reassembles
11
+ them into complete calls. The assembler is a pure, network-free object so it can
12
+ be unit-tested directly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import Any, AsyncIterator
22
+ from urllib.parse import urlparse
23
+
24
+ import httpx
25
+
26
+ from kiwimatecoder import network, telemetry
27
+ from kiwimatecoder.providers import ProviderConfig
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Stream events
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass
36
+ class TextDelta:
37
+ """A chunk of assistant text content."""
38
+
39
+ text: str
40
+
41
+
42
+ @dataclass
43
+ class ToolCallDelta:
44
+ """A fragment of a tool call. Fragments share an ``index`` per call."""
45
+
46
+ index: int
47
+ id: str | None = None
48
+ name: str | None = None
49
+ args_fragment: str = ""
50
+
51
+
52
+ @dataclass
53
+ class Usage:
54
+ """Token usage reported by the provider (when available)."""
55
+
56
+ prompt_tokens: int = 0
57
+ completion_tokens: int = 0
58
+
59
+
60
+ @dataclass
61
+ class Done:
62
+ """Marks the end of a streamed completion."""
63
+
64
+ finish_reason: str | None = None
65
+
66
+
67
+ StreamEvent = TextDelta | ToolCallDelta | Usage | Done
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Tool-call assembly
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ @dataclass
76
+ class AssembledToolCall:
77
+ """A fully reassembled tool call ready for dispatch."""
78
+
79
+ id: str
80
+ name: str
81
+ arguments: str # raw JSON string as emitted by the model
82
+
83
+ def parse_arguments(self) -> dict[str, Any]:
84
+ """Parse ``arguments`` as JSON, returning ``{}`` for an empty string."""
85
+ if not self.arguments.strip():
86
+ return {}
87
+ result: dict[str, Any] = json.loads(self.arguments)
88
+ return result
89
+
90
+
91
+ class ToolCallAssembler:
92
+ """Reassembles indexed tool-call fragments from a streamed completion."""
93
+
94
+ def __init__(self) -> None:
95
+ self._calls: dict[int, dict[str, Any]] = {}
96
+ self._order: list[int] = []
97
+
98
+ def add(self, delta: ToolCallDelta) -> None:
99
+ slot = self._calls.get(delta.index)
100
+ if slot is None:
101
+ slot = {"id": None, "name": None, "arguments": ""}
102
+ self._calls[delta.index] = slot
103
+ self._order.append(delta.index)
104
+ if delta.id is not None:
105
+ slot["id"] = delta.id
106
+ if delta.name is not None:
107
+ slot["name"] = delta.name
108
+ if delta.args_fragment:
109
+ slot["arguments"] = str(slot.get("arguments") or "") + delta.args_fragment
110
+
111
+ def finalize(self) -> list[AssembledToolCall]:
112
+ """Return the assembled calls in the order they first appeared."""
113
+ result: list[AssembledToolCall] = []
114
+ for index in self._order:
115
+ slot = self._calls[index]
116
+ if not slot["name"]:
117
+ continue
118
+ result.append(
119
+ AssembledToolCall(
120
+ id=str(slot["id"] or f"call_{index}"),
121
+ name=str(slot["name"]),
122
+ arguments=str(slot["arguments"]),
123
+ )
124
+ )
125
+ return result
126
+
127
+ def __bool__(self) -> bool:
128
+ return bool(self._calls)
129
+
130
+
131
+ def parse_sse_chunk(data: str) -> list[StreamEvent]:
132
+ """Convert one ``data:`` SSE payload into stream events for OpenAI endpoints."""
133
+ if data.strip() == "[DONE]":
134
+ return [Done()]
135
+ try:
136
+ chunk = json.loads(data)
137
+ except json.JSONDecodeError:
138
+ return []
139
+
140
+ events: list[StreamEvent] = []
141
+
142
+ if usage := chunk.get("usage"):
143
+ events.append(
144
+ Usage(
145
+ prompt_tokens=usage.get("prompt_tokens", 0) or 0,
146
+ completion_tokens=usage.get("completion_tokens", 0) or 0,
147
+ )
148
+ )
149
+
150
+ for choice in chunk.get("choices", []):
151
+ delta = choice.get("delta") or {}
152
+ content = delta.get("content")
153
+ if content:
154
+ events.append(TextDelta(text=content))
155
+ for tc in delta.get("tool_calls", []) or []:
156
+ fn = tc.get("function") or {}
157
+ events.append(
158
+ ToolCallDelta(
159
+ index=tc.get("index", 0),
160
+ id=tc.get("id"),
161
+ name=fn.get("name"),
162
+ args_fragment=fn.get("arguments") or "",
163
+ )
164
+ )
165
+ if choice.get("finish_reason"):
166
+ events.append(Done(finish_reason=choice["finish_reason"]))
167
+
168
+ return events
169
+
170
+
171
+ def _anthropic_image_block(url: str) -> dict[str, Any] | None:
172
+ """Convert one OpenAI image URL into an Anthropic image block, or None."""
173
+ if url.startswith("data:"):
174
+ header, separator, payload = url[5:].partition(";base64,")
175
+ if not separator or not payload:
176
+ return None
177
+ return {
178
+ "type": "image",
179
+ "source": {
180
+ "type": "base64",
181
+ "media_type": header or "image/png",
182
+ "data": payload,
183
+ },
184
+ }
185
+ if url.startswith(("http://", "https://")):
186
+ return {"type": "image", "source": {"type": "url", "url": url}}
187
+ return None
188
+
189
+
190
+ def _anthropic_content_blocks(content: list[Any]) -> list[dict[str, Any]]:
191
+ """Convert OpenAI content parts (text/image_url) into Anthropic blocks.
192
+
193
+ Unrecognized parts are skipped so an unexpected part cannot break a whole
194
+ request.
195
+ """
196
+ blocks: list[dict[str, Any]] = []
197
+ for part in content:
198
+ if not isinstance(part, dict):
199
+ continue
200
+ part_type = part.get("type")
201
+ if part_type == "text":
202
+ text = part.get("text")
203
+ if text:
204
+ blocks.append({"type": "text", "text": str(text)})
205
+ elif part_type == "image_url":
206
+ image_url = part.get("image_url")
207
+ if isinstance(image_url, dict):
208
+ image_url = image_url.get("url")
209
+ block = _anthropic_image_block(str(image_url or ""))
210
+ if block is not None:
211
+ blocks.append(block)
212
+ return blocks
213
+
214
+
215
+ def format_anthropic_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
216
+ """Convert OpenAI-format message list to Anthropic (system_prompt, messages)."""
217
+ system_parts: list[str] = []
218
+ converted: list[dict[str, Any]] = []
219
+
220
+ for msg in messages:
221
+ role = msg.get("role")
222
+ content = msg.get("content")
223
+
224
+ if role == "system":
225
+ if content:
226
+ system_parts.append(str(content))
227
+ continue
228
+
229
+ if role == "user":
230
+ if isinstance(content, list):
231
+ converted.append(
232
+ {
233
+ "role": "user",
234
+ "content": _anthropic_content_blocks(content) or "",
235
+ }
236
+ )
237
+ else:
238
+ converted.append(
239
+ {"role": "user", "content": str(content) if content else ""}
240
+ )
241
+ elif role == "assistant":
242
+ tool_calls = msg.get("tool_calls")
243
+ if tool_calls:
244
+ blocks: list[dict[str, Any]] = []
245
+ if isinstance(content, list):
246
+ blocks.extend(_anthropic_content_blocks(content))
247
+ elif content:
248
+ blocks.append({"type": "text", "text": str(content)})
249
+ for idx, tc in enumerate(tool_calls):
250
+ fn = tc.get("function", {})
251
+ args_str = fn.get("arguments", "{}")
252
+ try:
253
+ parsed_args = json.loads(args_str) if args_str else {}
254
+ except Exception:
255
+ parsed_args = {}
256
+ blocks.append(
257
+ {
258
+ "type": "tool_use",
259
+ "id": tc.get("id") or f"call_{idx}",
260
+ "name": fn.get("name", ""),
261
+ "input": parsed_args,
262
+ }
263
+ )
264
+ converted.append({"role": "assistant", "content": blocks})
265
+ elif isinstance(content, list):
266
+ converted.append(
267
+ {
268
+ "role": "assistant",
269
+ "content": _anthropic_content_blocks(content) or "",
270
+ }
271
+ )
272
+ else:
273
+ converted.append(
274
+ {
275
+ "role": "assistant",
276
+ "content": str(content) if content is not None else "",
277
+ }
278
+ )
279
+ elif role == "tool":
280
+ tool_call_id = msg.get("tool_call_id") or ""
281
+ tool_content = str(content) if content is not None else ""
282
+ tool_block = {
283
+ "type": "tool_result",
284
+ "tool_use_id": tool_call_id,
285
+ "content": tool_content,
286
+ }
287
+ if (
288
+ converted
289
+ and converted[-1]["role"] == "user"
290
+ and isinstance(converted[-1]["content"], list)
291
+ ):
292
+ converted[-1]["content"].append(tool_block)
293
+ else:
294
+ converted.append({"role": "user", "content": [tool_block]})
295
+
296
+ system_prompt = "\n\n".join(system_parts)
297
+ return system_prompt, converted
298
+
299
+
300
+ # Flattened tool results and call arguments longer than this are cut so one huge
301
+ # file read (or write) cannot crowd the rest of the history out of a chat-only
302
+ # endpoint's context.
303
+ _FLATTENED_TEXT_LIMIT = 8000
304
+
305
+
306
+ def _clip(text: str) -> str:
307
+ """Cut ``text`` to the flattened-history limit, marking the cut."""
308
+ if len(text) <= _FLATTENED_TEXT_LIMIT:
309
+ return text
310
+ return text[:_FLATTENED_TEXT_LIMIT] + "\n…[truncated]"
311
+
312
+
313
+ def _content_text(content: Any) -> str:
314
+ """Return the text of a string or OpenAI content-parts value."""
315
+ if content is None:
316
+ return ""
317
+ if isinstance(content, list):
318
+ texts = [
319
+ str(part.get("text") or "")
320
+ for part in content
321
+ if isinstance(part, dict) and part.get("type") == "text"
322
+ ]
323
+ return "\n\n".join(text for text in texts if text)
324
+ return str(content)
325
+
326
+
327
+ def _content_parts(content: Any) -> list[Any]:
328
+ """Return ``content`` as a new list of OpenAI content parts."""
329
+ if isinstance(content, list):
330
+ return list(content)
331
+ text = str(content or "")
332
+ return [{"type": "text", "text": text}] if text else []
333
+
334
+
335
+ def _merge_content(first: Any, second: Any) -> Any:
336
+ """Join two message contents, producing parts if either side has parts."""
337
+ if isinstance(first, list) or isinstance(second, list):
338
+ return _content_parts(first) + _content_parts(second)
339
+ if not first:
340
+ return second
341
+ if not second:
342
+ return first
343
+ return f"{first}\n\n{second}"
344
+
345
+
346
+ def flatten_tool_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
347
+ """Rewrite an OpenAI-format transcript for a chat-only endpoint.
348
+
349
+ Tool calls become assistant text, tool results become user text, developer
350
+ messages become system messages, consecutive same-role turns are merged,
351
+ and the transcript is made to end on a user turn. Only ``role`` and
352
+ ``content`` survive. The input is never mutated.
353
+ """
354
+ flattened: list[dict[str, Any]] = []
355
+ tool_names: dict[str, str] = {}
356
+
357
+ for msg in messages:
358
+ role = msg.get("role")
359
+ content = msg.get("content")
360
+
361
+ if role in ("system", "developer"):
362
+ flattened.append({"role": "system", "content": _content_text(content)})
363
+ continue
364
+
365
+ if role == "assistant":
366
+ pieces: list[str] = []
367
+ text = _content_text(content)
368
+ if text.strip():
369
+ pieces.append(text)
370
+ for tc in msg.get("tool_calls") or []:
371
+ fn = tc.get("function") or {}
372
+ name = str(fn.get("name") or "tool")
373
+ if tc.get("id"):
374
+ tool_names[str(tc["id"])] = name
375
+ args = fn.get("arguments")
376
+ if not isinstance(args, str):
377
+ args = json.dumps(args, ensure_ascii=False, default=str) if args else ""
378
+ pieces.append(f"[called tool {name} with {_clip(args.strip()) or '{}'}]")
379
+ if not pieces:
380
+ continue
381
+ entry: dict[str, Any] = {"role": "assistant", "content": "\n\n".join(pieces)}
382
+ elif role == "tool":
383
+ name = tool_names.get(str(msg.get("tool_call_id") or ""), "tool")
384
+ result = _clip(_content_text(content))
385
+ entry = {"role": "user", "content": f"[result of {name}]\n{result}"}
386
+ else:
387
+ entry = {
388
+ "role": "user",
389
+ "content": list(content) if isinstance(content, list) else content or "",
390
+ }
391
+
392
+ previous = flattened[-1] if flattened else None
393
+ if previous is not None and previous["role"] == entry["role"]:
394
+ # ``previous`` was built here, never taken from the input.
395
+ previous["content"] = _merge_content(previous["content"], entry["content"])
396
+ else:
397
+ flattened.append(entry)
398
+
399
+ last_turn = next((m for m in reversed(flattened) if m["role"] != "system"), None)
400
+ if last_turn is not None and last_turn["role"] == "assistant":
401
+ # E.g. a partial reply left behind by a failed stream.
402
+ flattened.append({"role": "user", "content": "Continue."})
403
+ return flattened
404
+
405
+
406
+ def _image_url(part: Any) -> str | None:
407
+ """Return an ``image_url`` part's URL, or None for any other part."""
408
+ if not isinstance(part, dict) or part.get("type") != "image_url":
409
+ return None
410
+ image = part.get("image_url")
411
+ url = image.get("url") if isinstance(image, dict) else image
412
+ return str(url or "")
413
+
414
+
415
+ def limit_images(
416
+ messages: list[dict[str, Any]],
417
+ provider: ProviderConfig,
418
+ ) -> list[dict[str, Any]]:
419
+ """Drop images a provider would reject, noting each drop as text.
420
+
421
+ Applies ``provider.max_image_url_chars`` (oversized images) and then
422
+ ``provider.max_images_per_message`` (keeping the newest images) to every
423
+ message whose content is a parts list; a limit of 0 means none. A request
424
+ the endpoint rejects would otherwise fail on every later turn, since the
425
+ images stay in the history. The input is never mutated.
426
+ """
427
+ max_images = provider.max_images_per_message
428
+ max_chars = provider.max_image_url_chars
429
+ if not max_images and not max_chars:
430
+ return messages
431
+ limited: list[dict[str, Any]] = []
432
+ for msg in messages:
433
+ content = msg.get("content")
434
+ if not isinstance(content, list):
435
+ limited.append(msg)
436
+ continue
437
+ oversized = 0
438
+ kept: list[Any] = []
439
+ for part in content:
440
+ url = _image_url(part)
441
+ if url is not None and max_chars and len(url) > max_chars:
442
+ oversized += 1
443
+ continue
444
+ kept.append(part)
445
+ image_rows = [index for index, part in enumerate(kept) if _image_url(part) is not None]
446
+ excess = image_rows[: max(0, len(image_rows) - max_images)] if max_images else []
447
+ if not oversized and not excess:
448
+ limited.append(msg)
449
+ continue
450
+ dropped = set(excess)
451
+ parts = [part for index, part in enumerate(kept) if index not in dropped]
452
+ notes: list[str] = []
453
+ if oversized:
454
+ notes.append(f"{oversized} image(s) too large for {provider.name}")
455
+ if excess:
456
+ notes.append(
457
+ f"{len(excess)} earlier image(s): {provider.name} accepts at most "
458
+ f"{max_images} per message"
459
+ )
460
+ parts.insert(0, {"type": "text", "text": f"[omitted {'; '.join(notes)}]"})
461
+ limited.append({**msg, "content": parts})
462
+ return limited
463
+
464
+
465
+ def format_anthropic_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
466
+ """Convert OpenAI tool schemas to Anthropic tools format."""
467
+ if not tools:
468
+ return None
469
+ anthropic_tools: list[dict[str, Any]] = []
470
+ for t in tools:
471
+ fn = t.get("function", {})
472
+ anthropic_tools.append(
473
+ {
474
+ "name": fn.get("name", ""),
475
+ "description": fn.get("description", ""),
476
+ "input_schema": fn.get(
477
+ "parameters", {"type": "object", "properties": {}}
478
+ ),
479
+ }
480
+ )
481
+ return anthropic_tools
482
+
483
+
484
+ def parse_anthropic_sse_event(
485
+ event_type: str | None, data: str
486
+ ) -> list[StreamEvent]:
487
+ """Convert an Anthropic SSE event payload into stream events."""
488
+ if not data.strip():
489
+ return []
490
+ try:
491
+ chunk = json.loads(data)
492
+ except json.JSONDecodeError:
493
+ return []
494
+
495
+ if not isinstance(chunk, dict):
496
+ return []
497
+
498
+ c_type = chunk.get("type") or event_type
499
+
500
+ if c_type == "error":
501
+ err = chunk.get("error") or {}
502
+ msg = err.get("message") if isinstance(err, dict) else str(err)
503
+ raise ProviderError(f"Anthropic stream error: {msg}")
504
+
505
+ events: list[StreamEvent] = []
506
+
507
+ if c_type == "message_start":
508
+ msg = chunk.get("message") or {}
509
+ usage = msg.get("usage") or {}
510
+ in_tok = usage.get("input_tokens", 0) or 0
511
+ out_tok = usage.get("output_tokens", 0) or 0
512
+ if in_tok or out_tok:
513
+ events.append(Usage(prompt_tokens=in_tok, completion_tokens=out_tok))
514
+
515
+ elif c_type == "content_block_start":
516
+ idx = chunk.get("index", 0)
517
+ block = chunk.get("content_block") or {}
518
+ if block.get("type") == "tool_use":
519
+ events.append(
520
+ ToolCallDelta(
521
+ index=idx,
522
+ id=block.get("id"),
523
+ name=block.get("name"),
524
+ args_fragment="",
525
+ )
526
+ )
527
+ elif block.get("type") == "text" and block.get("text"):
528
+ events.append(TextDelta(text=block["text"]))
529
+
530
+ elif c_type == "content_block_delta":
531
+ idx = chunk.get("index", 0)
532
+ delta = chunk.get("delta") or {}
533
+ d_type = delta.get("type")
534
+ if d_type == "text_delta":
535
+ text = delta.get("text")
536
+ if text:
537
+ events.append(TextDelta(text=text))
538
+ elif d_type == "input_json_delta":
539
+ frag = delta.get("partial_json") or ""
540
+ events.append(ToolCallDelta(index=idx, args_fragment=frag))
541
+
542
+ elif c_type == "message_delta":
543
+ delta = chunk.get("delta") or {}
544
+ usage = chunk.get("usage") or {}
545
+ out_tok = usage.get("output_tokens", 0) or 0
546
+ if out_tok:
547
+ events.append(Usage(prompt_tokens=0, completion_tokens=out_tok))
548
+ stop_reason = delta.get("stop_reason")
549
+ if stop_reason:
550
+ events.append(Done(finish_reason=stop_reason))
551
+
552
+ elif c_type == "message_stop":
553
+ events.append(Done(finish_reason=None))
554
+
555
+ return events
556
+
557
+
558
+ # ---------------------------------------------------------------------------
559
+ # The client
560
+ # ---------------------------------------------------------------------------
561
+
562
+
563
+ class ProviderError(RuntimeError):
564
+ """Raised when a provider returns a non-200 response."""
565
+
566
+
567
+ class UnifiedClient:
568
+ """Streams chat completions against OpenAI-compatible or Anthropic providers."""
569
+
570
+ def __init__(
571
+ self,
572
+ provider: ProviderConfig,
573
+ api_key: str,
574
+ timeout: float = 120.0,
575
+ sampling: dict[str, Any] | None = None,
576
+ prompt_cache: bool = False,
577
+ ):
578
+ self.provider = provider
579
+ self.api_key = api_key
580
+ self.timeout = timeout
581
+ self.sampling = sampling or {}
582
+ self.prompt_cache = bool(prompt_cache)
583
+
584
+ @property
585
+ def is_anthropic(self) -> bool:
586
+ return self.provider.compat == "anthropic"
587
+
588
+ @property
589
+ def _url(self) -> str:
590
+ base = self.provider.base_url.rstrip("/")
591
+ if self.is_anthropic:
592
+ return self.provider.versioned_url(f"{base}/messages")
593
+ return self.provider.versioned_url(f"{base}/chat/completions")
594
+
595
+ def _headers(self) -> dict[str, str]:
596
+ # Auth headers are only sent when there is a key — local servers
597
+ # (Ollama, LM Studio) accept keyless requests.
598
+ headers = {"Content-Type": "application/json"}
599
+ if self.is_anthropic:
600
+ headers["anthropic-version"] = "2023-06-01"
601
+ if self.api_key:
602
+ headers["x-api-key"] = self.api_key
603
+ elif self.api_key:
604
+ # Cloud providers usually want Authorization: Bearer <key>; Azure
605
+ # OpenAI wants `api-key: <key>` with no prefix. The provider config
606
+ # carries both so custom providers can follow either scheme.
607
+ headers[self.provider.key_header] = (
608
+ f"{self.provider.key_prefix}{self.api_key}"
609
+ )
610
+ headers.update(self.provider.extra_headers)
611
+ return headers
612
+
613
+ def _payload(
614
+ self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, model: str
615
+ ) -> dict[str, Any]:
616
+ if self.is_anthropic:
617
+ system_prompt, anthropic_msgs = format_anthropic_messages(messages)
618
+ anthropic_tools = format_anthropic_tools(tools)
619
+ payload: dict[str, Any] = {
620
+ "model": model,
621
+ "messages": anthropic_msgs,
622
+ "max_tokens": int(self.sampling.get("max_tokens") or 8192),
623
+ "stream": True,
624
+ }
625
+ if self.sampling.get("temperature") is not None:
626
+ payload["temperature"] = self.sampling["temperature"]
627
+ if self.sampling.get("top_p") is not None:
628
+ payload["top_p"] = self.sampling["top_p"]
629
+ if system_prompt:
630
+ if self.prompt_cache:
631
+ # Anthropic prompt caching: mark the stable system prompt
632
+ # and the end of the tool definitions as cache breakpoints.
633
+ payload["system"] = [
634
+ {
635
+ "type": "text",
636
+ "text": system_prompt,
637
+ "cache_control": {"type": "ephemeral"},
638
+ }
639
+ ]
640
+ else:
641
+ payload["system"] = system_prompt
642
+ if anthropic_tools:
643
+ if self.prompt_cache:
644
+ anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"}
645
+ payload["tools"] = anthropic_tools
646
+ return payload
647
+
648
+ # OpenAI-compatible providers cache automatically; no payload change. The
649
+ # ``prompt_cache`` toggle only affects native Anthropic requests.
650
+
651
+ # Chat-only endpoints (KiwiMate) reject tool traffic: flatten it, send no tools.
652
+ chat_only = not self.provider.supports_tools
653
+ if chat_only:
654
+ messages = flatten_tool_messages(messages)
655
+ openai_payload: dict[str, Any] = {
656
+ "model": model,
657
+ "messages": limit_images(messages, self.provider),
658
+ "stream": True,
659
+ "stream_options": {"include_usage": True},
660
+ }
661
+ for key in ("temperature", "top_p", "max_tokens", "reasoning_effort"):
662
+ value = self.sampling.get(key)
663
+ if value is not None:
664
+ openai_payload[key] = value
665
+ if tools and not chat_only:
666
+ openai_payload["tools"] = tools
667
+ # Ollama rejects tool_choice; "auto" is the default behavior anyway.
668
+ if not self.provider.is_local:
669
+ openai_payload["tool_choice"] = "auto"
670
+ return openai_payload
671
+
672
+ async def stream_chat(
673
+ self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, model: str
674
+ ) -> AsyncIterator[StreamEvent]:
675
+ """Yield :class:`StreamEvent` objects for one completion, with retry on transient errors."""
676
+ # There are no default models, so an unset model fails here (as a
677
+ # failover-compatible ProviderError) rather than as an HTTP 400.
678
+ if not model or not model.strip():
679
+ raise ProviderError(
680
+ f"No model chosen for {self.provider.name}; "
681
+ "choose one with /model or pass --model."
682
+ )
683
+ if not self.provider.is_local and network.offline_enabled():
684
+ raise ProviderError(
685
+ network.offline_message(f"{self.provider.name} chat completions")
686
+ )
687
+ payload = self._payload(messages, tools, model)
688
+ headers = self._headers()
689
+ url = self._url
690
+ host = urlparse(url).hostname or ""
691
+ connect_options = network.current_options()
692
+
693
+ max_attempts = 3
694
+ backoff_delays = [0.5, 1.0, 2.0]
695
+
696
+ for attempt in range(max_attempts):
697
+ started = time.perf_counter()
698
+ try:
699
+ async with httpx.AsyncClient(
700
+ timeout=self.timeout, **connect_options
701
+ ) as client:
702
+ async with client.stream(
703
+ "POST", url, json=payload, headers=headers
704
+ ) as response:
705
+ if (
706
+ response.status_code in (429, 500, 502, 503, 504)
707
+ and attempt < max_attempts - 1
708
+ ):
709
+ telemetry.log_event(
710
+ "provider_retry",
711
+ level="error",
712
+ provider=self.provider.id,
713
+ host=host,
714
+ status=response.status_code,
715
+ attempt=attempt + 1,
716
+ )
717
+ await asyncio.sleep(backoff_delays[attempt])
718
+ continue
719
+
720
+ telemetry.log_event(
721
+ "http_request",
722
+ level="debug",
723
+ method="POST",
724
+ host=host,
725
+ status=response.status_code,
726
+ duration_ms=int((time.perf_counter() - started) * 1000),
727
+ provider=self.provider.id,
728
+ )
729
+
730
+ if response.status_code != 200:
731
+ body = (await response.aread()).decode(
732
+ "utf-8", "replace"
733
+ )
734
+ telemetry.log_event(
735
+ "provider_error",
736
+ level="error",
737
+ provider=self.provider.id,
738
+ model=model,
739
+ status=response.status_code,
740
+ )
741
+ raise ProviderError(
742
+ f"{self.provider.name} returned HTTP {response.status_code}: "
743
+ f"{body[:500]}"
744
+ )
745
+
746
+ current_event_type: str | None = None
747
+ async for line in response.aiter_lines():
748
+ if self.is_anthropic and line.startswith("event: "):
749
+ current_event_type = line[7:].strip()
750
+ continue
751
+ if not line.startswith("data: "):
752
+ continue
753
+ data_part = line[6:]
754
+
755
+ if not self.is_anthropic:
756
+ try:
757
+ chunk = json.loads(data_part)
758
+ if (
759
+ isinstance(chunk, dict)
760
+ and "error" in chunk
761
+ ):
762
+ err = chunk["error"]
763
+ msg = (
764
+ err.get("message")
765
+ if isinstance(err, dict)
766
+ else str(err)
767
+ )
768
+ raise ProviderError(
769
+ f"{self.provider.name} stream error: {str(msg)[:500]}"
770
+ )
771
+ except (
772
+ json.JSONDecodeError,
773
+ TypeError,
774
+ AttributeError,
775
+ KeyError,
776
+ ):
777
+ pass
778
+
779
+ events = (
780
+ parse_anthropic_sse_event(
781
+ current_event_type, data_part
782
+ )
783
+ if self.is_anthropic
784
+ else parse_sse_chunk(data_part)
785
+ )
786
+ for event in events:
787
+ yield event
788
+ if (
789
+ isinstance(event, Done)
790
+ and event.finish_reason is None
791
+ ):
792
+ return
793
+ return
794
+ except (
795
+ httpx.ConnectTimeout,
796
+ httpx.ReadTimeout,
797
+ httpx.ConnectError,
798
+ httpx.RemoteProtocolError,
799
+ ) as exc:
800
+ if attempt < max_attempts - 1:
801
+ telemetry.log_event(
802
+ "provider_retry",
803
+ level="error",
804
+ provider=self.provider.id,
805
+ host=host,
806
+ error=exc.__class__.__name__,
807
+ attempt=attempt + 1,
808
+ )
809
+ await asyncio.sleep(backoff_delays[attempt])
810
+ continue
811
+ telemetry.log_event(
812
+ "provider_error",
813
+ level="error",
814
+ provider=self.provider.id,
815
+ host=host,
816
+ error=exc.__class__.__name__,
817
+ )
818
+ raise ProviderError(
819
+ f"{self.provider.name} connection error: {exc}"
820
+ ) from exc