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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/client.py
ADDED
|
@@ -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
|