devcopilot 0.2.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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,346 @@
1
+ """Track content-block state for native Anthropic SSE strings we emit to clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from collections.abc import Iterator
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from core.anthropic.sse import SSEBuilder, format_sse_event
12
+ from core.anthropic.stream_contracts import SSEEvent, parse_sse_lines
13
+ from core.anthropic.stream_recovery import (
14
+ ToolSchema,
15
+ accept_tool_json_repair,
16
+ continuation_suffix,
17
+ parse_complete_tool_input,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class EmittedBlockState:
23
+ """Tracked downstream block payload emitted to the client."""
24
+
25
+ index: int
26
+ block_type: str
27
+ open: bool = True
28
+ tool_id: str = ""
29
+ name: str = ""
30
+ parts: list[str] = field(default_factory=list)
31
+
32
+ @property
33
+ def content(self) -> str:
34
+ return "".join(self.parts)
35
+
36
+
37
+ class EmittedNativeSseTracker:
38
+ """Parse emitted SSE frames so mid-stream errors can close blocks and pick a fresh index."""
39
+
40
+ def __init__(self) -> None:
41
+ self._buf = ""
42
+ self._open_stack: list[int] = []
43
+ self._max_index = -1
44
+ self._blocks: dict[int, EmittedBlockState] = {}
45
+ self.message_id: str | None = None
46
+ self.model: str = ""
47
+ self.stop_reason: str | None = None
48
+ self.message_stopped = False
49
+
50
+ def feed(self, chunk: str) -> None:
51
+ """Record SSE frames completed by ``chunk`` (handles splitting across reads)."""
52
+ self._buf += chunk
53
+ while True:
54
+ sep = self._buf.find("\n\n")
55
+ if sep < 0:
56
+ break
57
+ frame = self._buf[:sep]
58
+ self._buf = self._buf[sep + 2 :]
59
+ if not frame.strip():
60
+ continue
61
+ for event in parse_sse_lines(frame.splitlines()):
62
+ self._observe(event)
63
+
64
+ def _observe(self, event: SSEEvent) -> None:
65
+ if event.event == "message_start":
66
+ message = event.data.get("message")
67
+ if isinstance(message, dict):
68
+ mid = message.get("id")
69
+ if isinstance(mid, str) and mid:
70
+ self.message_id = mid
71
+ model = message.get("model")
72
+ if isinstance(model, str) and model:
73
+ self.model = model
74
+ return
75
+
76
+ if event.event == "content_block_start":
77
+ raw_index = event.data.get("index")
78
+ if not isinstance(raw_index, int):
79
+ return
80
+ idx = raw_index
81
+ self._max_index = max(self._max_index, idx)
82
+ self._open_stack.append(idx)
83
+ block = event.data.get("content_block")
84
+ if isinstance(block, dict):
85
+ block_type = str(block.get("type", ""))
86
+ state = EmittedBlockState(index=idx, block_type=block_type)
87
+ if block_type == "tool_use":
88
+ tool_id = block.get("id")
89
+ name = block.get("name")
90
+ state.tool_id = tool_id if isinstance(tool_id, str) else ""
91
+ state.name = name if isinstance(name, str) else ""
92
+ elif block_type == "text":
93
+ text = block.get("text")
94
+ if isinstance(text, str) and text:
95
+ state.parts.append(text)
96
+ elif block_type == "thinking":
97
+ thinking = block.get("thinking")
98
+ if isinstance(thinking, str) and thinking:
99
+ state.parts.append(thinking)
100
+ self._blocks[idx] = state
101
+ return
102
+
103
+ if event.event == "content_block_delta":
104
+ raw_index = event.data.get("index")
105
+ if not isinstance(raw_index, int):
106
+ return
107
+ idx = raw_index
108
+ state = self._blocks.get(idx)
109
+ delta = event.data.get("delta")
110
+ if state is not None and isinstance(delta, dict):
111
+ if state.block_type == "text":
112
+ text = delta.get("text")
113
+ if isinstance(text, str):
114
+ state.parts.append(text)
115
+ elif state.block_type == "thinking":
116
+ thinking = delta.get("thinking")
117
+ if isinstance(thinking, str):
118
+ state.parts.append(thinking)
119
+ elif state.block_type == "tool_use":
120
+ partial = delta.get("partial_json")
121
+ if isinstance(partial, str):
122
+ state.parts.append(partial)
123
+ return
124
+
125
+ if event.event == "content_block_stop":
126
+ raw_index = event.data.get("index")
127
+ if not isinstance(raw_index, int):
128
+ return
129
+ idx = raw_index
130
+ if self._open_stack and self._open_stack[-1] == idx:
131
+ self._open_stack.pop()
132
+ else:
133
+ with suppress(ValueError):
134
+ self._open_stack.remove(idx)
135
+ state = self._blocks.get(idx)
136
+ if state is not None:
137
+ state.open = False
138
+ return
139
+
140
+ if event.event == "message_delta":
141
+ delta = event.data.get("delta")
142
+ if isinstance(delta, dict):
143
+ stop_reason = delta.get("stop_reason")
144
+ if isinstance(stop_reason, str):
145
+ self.stop_reason = stop_reason
146
+ return
147
+
148
+ if event.event == "message_stop":
149
+ self.message_stopped = True
150
+
151
+ def next_content_index(self) -> int:
152
+ """Next unused content block index based on emitted starts."""
153
+ return self._max_index + 1
154
+
155
+ def iter_close_unclosed_blocks(self) -> Iterator[str]:
156
+ """Yield ``content_block_stop`` events for blocks that were started but not stopped."""
157
+ while self._open_stack:
158
+ idx = self._open_stack.pop()
159
+ state = self._blocks.get(idx)
160
+ if state is not None:
161
+ state.open = False
162
+ yield format_sse_event(
163
+ "content_block_stop",
164
+ {"type": "content_block_stop", "index": idx},
165
+ )
166
+
167
+ def emitted_text(self) -> str:
168
+ return "".join(
169
+ block.content
170
+ for block in self._blocks.values()
171
+ if block.block_type == "text"
172
+ )
173
+
174
+ def emitted_thinking(self) -> str:
175
+ return "".join(
176
+ block.content
177
+ for block in self._blocks.values()
178
+ if block.block_type == "thinking"
179
+ )
180
+
181
+ def has_tool_block(self) -> bool:
182
+ return any(block.block_type == "tool_use" for block in self._blocks.values())
183
+
184
+ def has_content_block(self) -> bool:
185
+ return bool(self._blocks)
186
+
187
+ def has_terminal_message(self) -> bool:
188
+ return self.message_stopped
189
+
190
+ def tool_blocks(self) -> list[EmittedBlockState]:
191
+ return [
192
+ block for block in self._blocks.values() if block.block_type == "tool_use"
193
+ ]
194
+
195
+ def can_salvage_tool_use(self, schemas: dict[str, ToolSchema]) -> bool:
196
+ tool_blocks = self.tool_blocks()
197
+ if not tool_blocks:
198
+ return False
199
+ for block in tool_blocks:
200
+ if not block.tool_id or not block.name:
201
+ return False
202
+ if parse_complete_tool_input(block.content, block.name, schemas) is None:
203
+ return False
204
+ return True
205
+
206
+ def append_text_suffix(self, suffix: str) -> Iterator[str]:
207
+ if not suffix:
208
+ return
209
+ active = self._last_open_block("text")
210
+ if active is None:
211
+ index = self.next_content_index()
212
+ self._max_index = max(self._max_index, index)
213
+ active = EmittedBlockState(index=index, block_type="text")
214
+ self._blocks[index] = active
215
+ self._open_stack.append(index)
216
+ yield format_sse_event(
217
+ "content_block_start",
218
+ {
219
+ "type": "content_block_start",
220
+ "index": index,
221
+ "content_block": {"type": "text", "text": ""},
222
+ },
223
+ )
224
+ active.parts.append(suffix)
225
+ yield format_sse_event(
226
+ "content_block_delta",
227
+ {
228
+ "type": "content_block_delta",
229
+ "index": active.index,
230
+ "delta": {"type": "text_delta", "text": suffix},
231
+ },
232
+ )
233
+
234
+ def append_thinking_suffix(self, suffix: str) -> Iterator[str]:
235
+ if not suffix:
236
+ return
237
+ active = self._last_open_block("thinking")
238
+ if active is None:
239
+ index = self.next_content_index()
240
+ self._max_index = max(self._max_index, index)
241
+ active = EmittedBlockState(index=index, block_type="thinking")
242
+ self._blocks[index] = active
243
+ self._open_stack.append(index)
244
+ yield format_sse_event(
245
+ "content_block_start",
246
+ {
247
+ "type": "content_block_start",
248
+ "index": index,
249
+ "content_block": {"type": "thinking", "thinking": ""},
250
+ },
251
+ )
252
+ active.parts.append(suffix)
253
+ yield format_sse_event(
254
+ "content_block_delta",
255
+ {
256
+ "type": "content_block_delta",
257
+ "index": active.index,
258
+ "delta": {"type": "thinking_delta", "thinking": suffix},
259
+ },
260
+ )
261
+
262
+ def append_tool_repair_suffix(
263
+ self,
264
+ tool_index: int,
265
+ suffix: str,
266
+ ) -> Iterator[str]:
267
+ tool_blocks = self.tool_blocks()
268
+ if tool_index >= len(tool_blocks) or not suffix:
269
+ return
270
+ block = tool_blocks[tool_index]
271
+ block.parts.append(suffix)
272
+ yield format_sse_event(
273
+ "content_block_delta",
274
+ {
275
+ "type": "content_block_delta",
276
+ "index": block.index,
277
+ "delta": {"type": "input_json_delta", "partial_json": suffix},
278
+ },
279
+ )
280
+
281
+ def iter_success_tail(self, stop_reason: str) -> Iterator[str]:
282
+ yield from self.iter_close_unclosed_blocks()
283
+ if self.stop_reason is None:
284
+ yield format_sse_event(
285
+ "message_delta",
286
+ {
287
+ "type": "message_delta",
288
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
289
+ "usage": {"input_tokens": 0, "output_tokens": 1},
290
+ },
291
+ )
292
+ if not self.message_stopped:
293
+ yield format_sse_event("message_stop", {"type": "message_stop"})
294
+
295
+ def accept_tool_repair(
296
+ self,
297
+ tool_index: int,
298
+ candidate: str,
299
+ schemas: dict[str, ToolSchema],
300
+ ) -> str | None:
301
+ tool_blocks = self.tool_blocks()
302
+ if tool_index >= len(tool_blocks):
303
+ return None
304
+ block = tool_blocks[tool_index]
305
+ repair = accept_tool_json_repair(
306
+ block.content,
307
+ candidate,
308
+ tool_name=block.name,
309
+ schemas=schemas,
310
+ )
311
+ return repair.suffix if repair is not None else None
312
+
313
+ def continuation_text_suffix(self, candidate: str) -> str | None:
314
+ return continuation_suffix(self.emitted_text(), candidate)
315
+
316
+ def continuation_thinking_suffix(self, candidate: str) -> str | None:
317
+ return continuation_suffix(self.emitted_thinking(), candidate)
318
+
319
+ def iter_midstream_error_tail(
320
+ self,
321
+ error_message: str,
322
+ *,
323
+ request: Any,
324
+ input_tokens: int,
325
+ log_raw_sse_events: bool,
326
+ ) -> Iterator[str]:
327
+ """Close dangling blocks, emit a text error block at a fresh index, then message tail."""
328
+ mid = self.message_id or f"msg_{uuid.uuid4()}"
329
+ model = self.model or (getattr(request, "model", "") or "")
330
+ sse = SSEBuilder(
331
+ mid,
332
+ model,
333
+ input_tokens,
334
+ log_raw_events=log_raw_sse_events,
335
+ )
336
+ sse.blocks.next_index = self.next_content_index()
337
+ yield from sse.emit_error(error_message)
338
+ yield sse.message_delta("end_turn", 1)
339
+ yield sse.message_stop()
340
+
341
+ def _last_open_block(self, block_type: str) -> EmittedBlockState | None:
342
+ for index in reversed(self._open_stack):
343
+ block = self._blocks.get(index)
344
+ if block is not None and block.block_type == block_type and block.open:
345
+ return block
346
+ return None
@@ -0,0 +1,70 @@
1
+ """User-facing error formatting shared by API, providers, and integrations."""
2
+
3
+ import httpx
4
+ import openai
5
+
6
+
7
+ def get_user_facing_error_message(
8
+ e: Exception,
9
+ *,
10
+ read_timeout_s: float | None = None,
11
+ ) -> str:
12
+ """Return a readable, non-empty error message for users.
13
+
14
+ Known transport and OpenAI SDK exception types are mapped to stable wording
15
+ before falling back to ``str(e)``, so empty or noisy SDK messages do not skip
16
+ the mapped path.
17
+ """
18
+ if isinstance(e, httpx.ReadTimeout):
19
+ if read_timeout_s is not None:
20
+ return f"Provider request timed out after {read_timeout_s:g}s."
21
+ return "Provider request timed out."
22
+ if isinstance(e, httpx.ConnectTimeout):
23
+ return "Could not connect to provider."
24
+ if isinstance(e, TimeoutError):
25
+ if read_timeout_s is not None:
26
+ return f"Provider request timed out after {read_timeout_s:g}s."
27
+ return "Request timed out."
28
+
29
+ if isinstance(e, openai.RateLimitError):
30
+ return "Provider rate limit reached. Please retry shortly."
31
+ if isinstance(e, openai.AuthenticationError):
32
+ return "Provider authentication failed. Check API key."
33
+ if isinstance(e, openai.BadRequestError):
34
+ return "Invalid request sent to provider."
35
+
36
+ name = type(e).__name__
37
+ status_code = getattr(e, "status_code", None)
38
+ if name == "RateLimitError":
39
+ return "Provider rate limit reached. Please retry shortly."
40
+ if name == "AuthenticationError":
41
+ return "Provider authentication failed. Check API key."
42
+ if name == "InvalidRequestError":
43
+ return "Invalid request sent to provider."
44
+ if name == "OverloadedError":
45
+ return "Provider is currently overloaded. Please retry."
46
+ if name == "APIError":
47
+ if status_code in (502, 503, 504):
48
+ return "Provider is temporarily unavailable. Please retry."
49
+ return "Provider API request failed."
50
+ if name.endswith("ProviderError") or name == "ProviderError":
51
+ return "Provider request failed."
52
+
53
+ message = str(e).strip()
54
+ if message:
55
+ return message
56
+
57
+ return "Provider request failed unexpectedly."
58
+
59
+
60
+ def format_user_error_preview(exc: Exception, *, max_len: int = 200) -> str:
61
+ """Truncate a user-facing error string for short chat replies."""
62
+ return get_user_facing_error_message(exc)[:max_len]
63
+
64
+
65
+ def append_request_id(message: str, request_id: str | None) -> str:
66
+ """Append request_id suffix when available."""
67
+ base = message.strip() or "Provider request failed unexpectedly."
68
+ if request_id:
69
+ return f"{base} (request_id={request_id})"
70
+ return base
@@ -0,0 +1,280 @@
1
+ """Native Anthropic Messages request body construction (JSON-ready dicts).
2
+
3
+ Provider adapters supply policy via parameters (defaults, OpenRouter post-steps).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel
12
+
13
+ _REQUEST_FIELDS = (
14
+ "model",
15
+ "messages",
16
+ "system",
17
+ "max_tokens",
18
+ "stop_sequences",
19
+ "stream",
20
+ "temperature",
21
+ "top_p",
22
+ "top_k",
23
+ "metadata",
24
+ "tools",
25
+ "tool_choice",
26
+ "thinking",
27
+ "context_management",
28
+ "output_config",
29
+ "mcp_servers",
30
+ "extra_body",
31
+ )
32
+
33
+ # Keys that would override routed canonical request fields if merged from ``extra_body``.
34
+ _OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS = frozenset(
35
+ {
36
+ "model",
37
+ "messages",
38
+ "system",
39
+ "tools",
40
+ "tool_choice",
41
+ "stream",
42
+ "max_tokens",
43
+ "temperature",
44
+ "top_p",
45
+ "top_k",
46
+ "metadata",
47
+ "stop_sequences",
48
+ "context_management",
49
+ "output_config",
50
+ "mcp_servers",
51
+ }
52
+ )
53
+
54
+
55
+ class OpenRouterExtraBodyError(ValueError):
56
+ """``extra_body`` contained reserved keys that would override canonical fields."""
57
+
58
+
59
+ def validate_openrouter_extra_body(extra: Any) -> None:
60
+ """Reject ``extra_body`` keys that must not override routed request fields."""
61
+ if not isinstance(extra, dict) or not extra:
62
+ return
63
+ bad = _OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS & extra.keys()
64
+ if bad:
65
+ raise OpenRouterExtraBodyError(
66
+ f"extra_body must not override canonical request fields: {sorted(bad)}"
67
+ )
68
+
69
+
70
+ _INTERNAL_FIELDS = {
71
+ "thinking",
72
+ "extra_body",
73
+ }
74
+
75
+
76
+ def _serialize_value(value: Any) -> Any:
77
+ """Convert Pydantic models and lightweight objects into JSON-ready values."""
78
+ if isinstance(value, BaseModel):
79
+ return value.model_dump(exclude_none=True)
80
+ if isinstance(value, dict):
81
+ return {
82
+ key: _serialize_value(item)
83
+ for key, item in value.items()
84
+ if item is not None
85
+ }
86
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
87
+ return [_serialize_value(item) for item in value]
88
+ if value is None or isinstance(value, str | int | float | bool):
89
+ return value
90
+ if hasattr(value, "__dict__"):
91
+ return {
92
+ key: _serialize_value(item)
93
+ for key, item in vars(value).items()
94
+ if not key.startswith("_") and item is not None
95
+ }
96
+ return value
97
+
98
+
99
+ def _dump_request_fields(request_data: Any) -> dict[str, Any]:
100
+ """Extract the public request fields (OpenRouter-style explicit field list)."""
101
+ if isinstance(request_data, BaseModel):
102
+ raw = request_data.model_dump(exclude_none=True)
103
+ return {
104
+ field: raw[field]
105
+ for field in _REQUEST_FIELDS
106
+ if field in raw and raw[field] is not None
107
+ }
108
+
109
+ dump = getattr(request_data, "model_dump", None)
110
+ if callable(dump):
111
+ raw = dump(exclude_none=True)
112
+ if isinstance(raw, dict):
113
+ return {
114
+ field: raw[field]
115
+ for field in _REQUEST_FIELDS
116
+ if field in raw and raw[field] is not None
117
+ }
118
+
119
+ dumped: dict[str, Any] = {}
120
+ for field in _REQUEST_FIELDS:
121
+ value = getattr(request_data, field, None)
122
+ if value is not None:
123
+ dumped[field] = _serialize_value(value)
124
+ return dumped
125
+
126
+
127
+ def dump_raw_messages_request(request_data: Any) -> dict[str, Any]:
128
+ """Public JSON-ready dict of Anthropic public request fields (for native adapters)."""
129
+ return _dump_request_fields(request_data)
130
+
131
+
132
+ def sanitize_native_messages_thinking_policy(
133
+ messages: Any, *, thinking_enabled: bool
134
+ ) -> Any:
135
+ """Filter assistant message thinking blocks for upstream native Anthropic JSON.
136
+
137
+ When ``thinking_enabled`` is false, remove ``thinking`` and ``redacted_thinking``
138
+ history so disabled policy is not undermined by prior turns.
139
+
140
+ When true, keep ``redacted_thinking`` and signed ``thinking``; remove only
141
+ unsigned plain ``thinking`` blocks (not replayable).
142
+ """
143
+ if not isinstance(messages, list):
144
+ return messages
145
+
146
+ sanitized_messages: list[Any] = []
147
+ for message in messages:
148
+ if not isinstance(message, dict):
149
+ sanitized_messages.append(message)
150
+ continue
151
+
152
+ if message.get("role") != "assistant":
153
+ sanitized_messages.append(message)
154
+ continue
155
+
156
+ content = message.get("content")
157
+ if not isinstance(content, list):
158
+ sanitized_messages.append(message)
159
+ continue
160
+
161
+ if not thinking_enabled:
162
+ sanitized_content = [
163
+ block
164
+ for block in content
165
+ if not (
166
+ isinstance(block, dict)
167
+ and block.get("type") in ("thinking", "redacted_thinking")
168
+ )
169
+ ]
170
+ else:
171
+ sanitized_content = [
172
+ block
173
+ for block in content
174
+ if not (
175
+ isinstance(block, dict)
176
+ and block.get("type") == "thinking"
177
+ and not isinstance(block.get("signature"), str)
178
+ )
179
+ ]
180
+
181
+ sanitized_message = dict(message)
182
+ sanitized_message["content"] = sanitized_content or ""
183
+ sanitized_messages.append(sanitized_message)
184
+
185
+ return sanitized_messages
186
+
187
+
188
+ def _normalize_system_prompt_for_openrouter(system: Any) -> Any:
189
+ """Flatten Claude SDK system blocks for OpenRouter's native endpoint."""
190
+ if not isinstance(system, list):
191
+ return system
192
+
193
+ text_parts: list[str] = []
194
+ for block in system:
195
+ if not isinstance(block, dict):
196
+ continue
197
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
198
+ text_parts.append(block["text"])
199
+ return "\n\n".join(text_parts).strip() if text_parts else system
200
+
201
+
202
+ def _apply_openrouter_reasoning_policy(body: dict[str, Any], thinking_cfg: Any) -> None:
203
+ """Map Anthropic thinking controls onto OpenRouter reasoning controls."""
204
+ reasoning = body.setdefault("reasoning", {"enabled": True})
205
+ if not isinstance(reasoning, dict):
206
+ return
207
+ reasoning.setdefault("enabled", True)
208
+ if not isinstance(thinking_cfg, dict):
209
+ return
210
+ budget_tokens = thinking_cfg.get("budget_tokens")
211
+ if isinstance(budget_tokens, int):
212
+ reasoning.setdefault("max_tokens", budget_tokens)
213
+
214
+
215
+ def build_base_native_anthropic_request_body(
216
+ request: Any,
217
+ *,
218
+ default_max_tokens: int,
219
+ thinking_enabled: bool,
220
+ ) -> dict[str, Any]:
221
+ """Serialize a Pydantic messages request to a generic native Anthropic body."""
222
+ body = dump_raw_messages_request(request)
223
+
224
+ body.pop("extra_body", None)
225
+
226
+ if "thinking" in body:
227
+ thinking_cfg = body.pop("thinking")
228
+ if thinking_enabled and isinstance(thinking_cfg, dict):
229
+ thinking_payload: dict[str, Any] = {"type": "enabled"}
230
+ budget_tokens = thinking_cfg.get("budget_tokens")
231
+ if isinstance(budget_tokens, int):
232
+ thinking_payload["budget_tokens"] = budget_tokens
233
+ body["thinking"] = thinking_payload
234
+
235
+ if "max_tokens" not in body:
236
+ body["max_tokens"] = default_max_tokens
237
+
238
+ if "messages" in body:
239
+ body["messages"] = sanitize_native_messages_thinking_policy(
240
+ body["messages"],
241
+ thinking_enabled=thinking_enabled,
242
+ )
243
+
244
+ return body
245
+
246
+
247
+ def build_openrouter_native_request_body(
248
+ request_data: Any,
249
+ *,
250
+ thinking_enabled: bool,
251
+ default_max_tokens: int,
252
+ ) -> dict[str, Any]:
253
+ """Build an Anthropic-format request body for OpenRouter (policy hooks built-in)."""
254
+ dumped_request = _dump_request_fields(request_data)
255
+ request_extra = dumped_request.pop("extra_body", None)
256
+ thinking_cfg = dumped_request.get("thinking")
257
+ body: dict[str, Any] = {
258
+ key: value
259
+ for key, value in dumped_request.items()
260
+ if key not in _INTERNAL_FIELDS
261
+ }
262
+
263
+ if isinstance(request_extra, dict):
264
+ validate_openrouter_extra_body(request_extra)
265
+ body.update(request_extra)
266
+
267
+ body["messages"] = sanitize_native_messages_thinking_policy(
268
+ body.get("messages"),
269
+ thinking_enabled=thinking_enabled,
270
+ )
271
+ if "system" in body:
272
+ body["system"] = _normalize_system_prompt_for_openrouter(body["system"])
273
+ body["stream"] = True
274
+ if body.get("max_tokens") is None:
275
+ body["max_tokens"] = default_max_tokens
276
+
277
+ if thinking_enabled:
278
+ _apply_openrouter_reasoning_policy(body, thinking_cfg)
279
+
280
+ return body