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,374 @@
1
+ """Tool conversion helpers for the OpenAI Responses adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal
11
+
12
+ from .errors import ResponsesConversionError
13
+ from .ids import new_call_id
14
+
15
+ _MAX_ANTHROPIC_TOOL_NAME_LEN = 64
16
+ _NAMESPACE_TOOL_SEPARATOR = "__"
17
+ _UNSUPPORTED_PASSIVE_TOOL_TYPES = frozenset(
18
+ {"web_search", "image_generation", "tool_search"}
19
+ )
20
+ _INVALID_TOOL_NAME_CHARS = re.compile(r"[^A-Za-z0-9_-]+")
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ResponsesToolIdentity:
25
+ kind: Literal["function", "custom"]
26
+ name: str
27
+ namespace: str | None = None
28
+
29
+
30
+ def convert_tools(value: Any) -> list[dict[str, Any]] | None:
31
+ if value is None:
32
+ return None
33
+ if not isinstance(value, list):
34
+ raise ResponsesConversionError("Responses tools must be a list")
35
+
36
+ tools: list[dict[str, Any]] = []
37
+ for tool in value:
38
+ if not isinstance(tool, dict):
39
+ raise ResponsesConversionError(
40
+ f"Unsupported Responses tool: {type(tool).__name__}"
41
+ )
42
+ tool_type = tool.get("type")
43
+ if tool_type == "function":
44
+ tools.append(_convert_function_tool(tool, namespace=None))
45
+ continue
46
+ if tool_type == "custom":
47
+ tools.append(_convert_custom_tool(tool, namespace=None))
48
+ continue
49
+ if tool_type == "namespace":
50
+ tools.extend(_convert_namespace_tool(tool))
51
+ continue
52
+ if tool_type in _UNSUPPORTED_PASSIVE_TOOL_TYPES:
53
+ continue
54
+ if tool_type != "function":
55
+ raise ResponsesConversionError(
56
+ f"Unsupported Responses tool type: {tool_type!r}"
57
+ )
58
+ return tools
59
+
60
+
61
+ def convert_tool_choice(value: Any) -> dict[str, Any] | None:
62
+ if value is None or value == "auto":
63
+ return None
64
+ if value == "none":
65
+ return None
66
+ if value == "required":
67
+ return {"type": "any"}
68
+ if isinstance(value, dict):
69
+ choice_type = value.get("type")
70
+ if choice_type == "function":
71
+ namespace = optional_str(value.get("namespace"))
72
+ name = required_str(value.get("name"), "tool_choice.name")
73
+ return {
74
+ "type": "tool",
75
+ "name": responses_tool_name_to_anthropic_name(
76
+ name, namespace=namespace
77
+ ),
78
+ }
79
+ if choice_type == "custom":
80
+ source = _custom_source(value)
81
+ namespace = optional_str(source.get("namespace")) or optional_str(
82
+ value.get("namespace")
83
+ )
84
+ name = required_str(source.get("name"), "tool_choice.name")
85
+ return {
86
+ "type": "tool",
87
+ "name": responses_tool_name_to_anthropic_name(
88
+ name, namespace=namespace
89
+ ),
90
+ }
91
+ if choice_type == "tool":
92
+ namespace = optional_str(value.get("namespace"))
93
+ name = optional_str(value.get("name"))
94
+ if name:
95
+ return {
96
+ "type": "tool",
97
+ "name": responses_tool_name_to_anthropic_name(
98
+ name, namespace=namespace
99
+ ),
100
+ }
101
+ return dict(value)
102
+ if choice_type in {"auto", "any"}:
103
+ return dict(value)
104
+ raise ResponsesConversionError(f"Unsupported Responses tool_choice: {value!r}")
105
+
106
+
107
+ def responses_tool_name_to_anthropic_name(
108
+ name: str, *, namespace: str | None = None
109
+ ) -> str:
110
+ """Return a deterministic Anthropic tool name for a Responses tool identity."""
111
+
112
+ if not namespace:
113
+ return name
114
+ combined = (
115
+ f"{_tool_name_part(namespace)}"
116
+ f"{_NAMESPACE_TOOL_SEPARATOR}"
117
+ f"{_tool_name_part(name)}"
118
+ )
119
+ if len(combined) <= _MAX_ANTHROPIC_TOOL_NAME_LEN:
120
+ return combined
121
+ digest = hashlib.sha1(combined.encode("utf-8")).hexdigest()[:8]
122
+ prefix_len = _MAX_ANTHROPIC_TOOL_NAME_LEN - len(digest) - 1
123
+ return f"{combined[:prefix_len]}_{digest}"
124
+
125
+
126
+ def responses_tool_identity_from_anthropic_name(
127
+ request: Mapping[str, Any], anthropic_name: str
128
+ ) -> ResponsesToolIdentity:
129
+ """Return the Responses namespace/name represented by an Anthropic tool name."""
130
+
131
+ tools = request.get("tools")
132
+ if not isinstance(tools, list):
133
+ return ResponsesToolIdentity(kind="function", name=anthropic_name)
134
+ for tool in tools:
135
+ if not isinstance(tool, dict):
136
+ continue
137
+ tool_type = tool.get("type")
138
+ if tool_type == "function":
139
+ source = tool.get("function")
140
+ function = source if isinstance(source, dict) else tool
141
+ if (name := optional_str(function.get("name"))) and (
142
+ responses_tool_name_to_anthropic_name(name) == anthropic_name
143
+ ):
144
+ return ResponsesToolIdentity(kind="function", name=name)
145
+ continue
146
+ if tool_type == "custom":
147
+ source = _custom_source(tool)
148
+ if (name := optional_str(source.get("name"))) and (
149
+ responses_tool_name_to_anthropic_name(name) == anthropic_name
150
+ ):
151
+ return ResponsesToolIdentity(kind="custom", name=name)
152
+ continue
153
+ if tool_type != "namespace":
154
+ continue
155
+ namespace = optional_str(tool.get("name"))
156
+ nested_tools = tool.get("tools")
157
+ if not namespace or not isinstance(nested_tools, list):
158
+ continue
159
+ for nested_tool in nested_tools:
160
+ if not isinstance(nested_tool, dict):
161
+ continue
162
+ nested_tool_type = nested_tool.get("type")
163
+ if nested_tool_type == "function":
164
+ source = nested_tool.get("function")
165
+ function = source if isinstance(source, dict) else nested_tool
166
+ if (name := optional_str(function.get("name"))) and (
167
+ responses_tool_name_to_anthropic_name(name, namespace=namespace)
168
+ == anthropic_name
169
+ ):
170
+ return ResponsesToolIdentity(
171
+ kind="function", name=name, namespace=namespace
172
+ )
173
+ continue
174
+ if nested_tool_type == "custom":
175
+ source = _custom_source(nested_tool)
176
+ if (name := optional_str(source.get("name"))) and (
177
+ responses_tool_name_to_anthropic_name(name, namespace=namespace)
178
+ == anthropic_name
179
+ ):
180
+ return ResponsesToolIdentity(
181
+ kind="custom", name=name, namespace=namespace
182
+ )
183
+ return ResponsesToolIdentity(kind="function", name=anthropic_name)
184
+
185
+
186
+ def parse_arguments(value: Any) -> dict[str, Any]:
187
+ if value is None or value == "":
188
+ return {}
189
+ if isinstance(value, dict):
190
+ return value
191
+ if not isinstance(value, str):
192
+ raise ResponsesConversionError("Responses function_call arguments must be JSON")
193
+ try:
194
+ parsed = json.loads(value)
195
+ except json.JSONDecodeError as exc:
196
+ raise ResponsesConversionError(
197
+ f"Responses function_call arguments are invalid JSON: {exc.msg}"
198
+ ) from exc
199
+ if not isinstance(parsed, dict):
200
+ raise ResponsesConversionError(
201
+ "Responses function_call arguments must decode to an object"
202
+ )
203
+ return parsed
204
+
205
+
206
+ def custom_tool_input_to_anthropic(value: Any) -> dict[str, str]:
207
+ return {"input": custom_tool_input_text(value)}
208
+
209
+
210
+ def custom_tool_input_text(value: Any) -> str:
211
+ if value is None:
212
+ return ""
213
+ if isinstance(value, str):
214
+ return value
215
+ return _json_dumps(value)
216
+
217
+
218
+ def custom_tool_input_text_from_anthropic(value: Any) -> str:
219
+ if isinstance(value, Mapping):
220
+ raw_input = value.get("input")
221
+ if isinstance(raw_input, str):
222
+ return raw_input
223
+ if raw_input is not None:
224
+ return custom_tool_input_text(raw_input)
225
+ if not value:
226
+ return ""
227
+ return _json_dumps(value)
228
+ return custom_tool_input_text(value)
229
+
230
+
231
+ def custom_tool_input_text_from_arguments(arguments: str) -> str:
232
+ if not arguments:
233
+ return ""
234
+ try:
235
+ parsed = json.loads(arguments)
236
+ except json.JSONDecodeError:
237
+ return arguments
238
+ return custom_tool_input_text_from_anthropic(parsed)
239
+
240
+
241
+ def call_id_from_item(item: Mapping[str, Any]) -> str:
242
+ for key in ("call_id", "id"):
243
+ if value := optional_str(item.get(key)):
244
+ return value
245
+ return new_call_id()
246
+
247
+
248
+ def required_str(value: Any, field_name: str) -> str:
249
+ if isinstance(value, str) and value:
250
+ return value
251
+ raise ResponsesConversionError(
252
+ f"Responses field {field_name} must be a non-empty string"
253
+ )
254
+
255
+
256
+ def optional_str(value: Any) -> str | None:
257
+ return value if isinstance(value, str) else None
258
+
259
+
260
+ def _convert_namespace_tool(tool: Mapping[str, Any]) -> list[dict[str, Any]]:
261
+ namespace = required_str(tool.get("name"), "tool.namespace.name")
262
+ nested_tools = tool.get("tools")
263
+ if not isinstance(nested_tools, list):
264
+ raise ResponsesConversionError(
265
+ f"Responses namespace tool {namespace!r} tools must be a list"
266
+ )
267
+
268
+ converted_tools: list[dict[str, Any]] = []
269
+ for nested_tool in nested_tools:
270
+ if not isinstance(nested_tool, dict):
271
+ raise ResponsesConversionError(
272
+ f"Unsupported Responses namespace tool: {type(nested_tool).__name__}"
273
+ )
274
+ nested_tool_type = nested_tool.get("type")
275
+ if nested_tool_type == "function":
276
+ converted_tools.append(
277
+ _convert_function_tool(nested_tool, namespace=namespace)
278
+ )
279
+ continue
280
+ if nested_tool_type == "custom":
281
+ converted_tools.append(
282
+ _convert_custom_tool(nested_tool, namespace=namespace)
283
+ )
284
+ continue
285
+ raise ResponsesConversionError(
286
+ f"Unsupported Responses namespace tool type: {nested_tool_type!r}"
287
+ )
288
+ return converted_tools
289
+
290
+
291
+ def _convert_function_tool(
292
+ tool: Mapping[str, Any], *, namespace: str | None
293
+ ) -> dict[str, Any]:
294
+ function = tool.get("function")
295
+ source = function if isinstance(function, dict) else tool
296
+ name = required_str(source.get("name"), "tool.name")
297
+ schema = source.get("parameters")
298
+ if schema is None:
299
+ schema = {"type": "object", "properties": {}}
300
+ if not isinstance(schema, dict):
301
+ raise ResponsesConversionError(
302
+ f"Responses tool {name!r} parameters must be an object"
303
+ )
304
+ converted: dict[str, Any] = {
305
+ "name": responses_tool_name_to_anthropic_name(name, namespace=namespace),
306
+ "input_schema": schema,
307
+ }
308
+ if description := optional_str(source.get("description")):
309
+ converted["description"] = description
310
+ return converted
311
+
312
+
313
+ def _convert_custom_tool(
314
+ tool: Mapping[str, Any], *, namespace: str | None
315
+ ) -> dict[str, Any]:
316
+ source = _custom_source(tool)
317
+ name = required_str(source.get("name"), "tool.name")
318
+ converted: dict[str, Any] = {
319
+ "name": responses_tool_name_to_anthropic_name(name, namespace=namespace),
320
+ "input_schema": {
321
+ "type": "object",
322
+ "properties": {
323
+ "input": {
324
+ "type": "string",
325
+ "description": "Free-form input for the custom tool.",
326
+ }
327
+ },
328
+ "required": ["input"],
329
+ },
330
+ }
331
+ if description := _custom_tool_description(source):
332
+ converted["description"] = description
333
+ return converted
334
+
335
+
336
+ def _custom_source(tool: Mapping[str, Any]) -> Mapping[str, Any]:
337
+ custom = tool.get("custom")
338
+ return custom if isinstance(custom, Mapping) else tool
339
+
340
+
341
+ def _custom_tool_description(source: Mapping[str, Any]) -> str | None:
342
+ parts: list[str] = []
343
+ if description := optional_str(source.get("description")):
344
+ parts.append(description)
345
+ format_value = source.get("format")
346
+ if isinstance(format_value, Mapping):
347
+ format_type = optional_str(format_value.get("type"))
348
+ if format_type == "text":
349
+ parts.append("Custom tool input format: unconstrained text.")
350
+ elif format_type == "grammar":
351
+ syntax = optional_str(format_value.get("syntax"))
352
+ definition = optional_str(format_value.get("definition"))
353
+ guidance = "Custom tool input format: grammar"
354
+ if syntax:
355
+ guidance = f"{guidance} ({syntax})"
356
+ guidance = f"{guidance}: {definition}" if definition else f"{guidance}."
357
+ parts.append(guidance)
358
+ elif format_type:
359
+ parts.append(f"Custom tool input format: {format_type}.")
360
+ else:
361
+ parts.append(f"Custom tool input format: {_json_dumps(format_value)}")
362
+ return "\n\n".join(parts) if parts else None
363
+
364
+
365
+ def _tool_name_part(value: str) -> str:
366
+ normalized = _INVALID_TOOL_NAME_CHARS.sub("_", value).strip("_")
367
+ return normalized or "tool"
368
+
369
+
370
+ def _json_dumps(value: Any) -> str:
371
+ try:
372
+ return json.dumps(value)
373
+ except TypeError:
374
+ return str(value)
@@ -0,0 +1,37 @@
1
+ """Usage helpers for OpenAI Responses payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ _DISALLOWED_SPECIAL: tuple[str, ...] = ()
8
+
9
+
10
+ class _TokenEncoder(Protocol):
11
+ def encode(
12
+ self, text: str, *, disallowed_special: tuple[str, ...]
13
+ ) -> list[int]: ...
14
+
15
+
16
+ def _load_encoder() -> _TokenEncoder | None:
17
+ try:
18
+ import tiktoken
19
+ except ImportError:
20
+ return None
21
+
22
+ try:
23
+ return tiktoken.get_encoding("cl100k_base")
24
+ except ValueError:
25
+ return None
26
+
27
+
28
+ _ENCODER = _load_encoder()
29
+
30
+
31
+ def estimate_text_tokens(text: str) -> int:
32
+ """Return a best-effort token estimate for Responses usage details."""
33
+ if not text:
34
+ return 0
35
+ if _ENCODER is not None:
36
+ return len(_ENCODER.encode(text, disallowed_special=_DISALLOWED_SPECIAL))
37
+ return max(1, len(text) // 4)
core/rate_limit.py ADDED
@@ -0,0 +1,60 @@
1
+ """Shared strict sliding-window rate limiting primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections import deque
8
+
9
+
10
+ class StrictSlidingWindowLimiter:
11
+ """Strict sliding window limiter.
12
+
13
+ Guarantees: at most ``rate_limit`` acquisitions in any interval of length
14
+ ``rate_window`` (seconds).
15
+
16
+ Implemented as an async context manager so call sites can do::
17
+
18
+ async with limiter:
19
+ ...
20
+ """
21
+
22
+ def __init__(self, rate_limit: int, rate_window: float) -> None:
23
+ if rate_limit <= 0:
24
+ raise ValueError("rate_limit must be > 0")
25
+ if rate_window <= 0:
26
+ raise ValueError("rate_window must be > 0")
27
+
28
+ self._rate_limit = int(rate_limit)
29
+ self._rate_window = float(rate_window)
30
+ self._times: deque[float] = deque()
31
+ self._lock = asyncio.Lock()
32
+
33
+ async def acquire(self) -> None:
34
+ while True:
35
+ wait_time = 0.0
36
+ async with self._lock:
37
+ now = time.monotonic()
38
+ cutoff = now - self._rate_window
39
+
40
+ while self._times and self._times[0] <= cutoff:
41
+ self._times.popleft()
42
+
43
+ if len(self._times) < self._rate_limit:
44
+ self._times.append(now)
45
+ return
46
+
47
+ oldest = self._times[0]
48
+ wait_time = max(0.0, (oldest + self._rate_window) - now)
49
+
50
+ if wait_time > 0:
51
+ await asyncio.sleep(wait_time)
52
+ else:
53
+ await asyncio.sleep(0)
54
+
55
+ async def __aenter__(self) -> StrictSlidingWindowLimiter:
56
+ await self.acquire()
57
+ return self
58
+
59
+ async def __aexit__(self, exc_type, exc, tb) -> bool:
60
+ return False
core/trace.py ADDED
@@ -0,0 +1,216 @@
1
+ """Structured TRACE events for end-to-end request / CLI / provider logging.
2
+
3
+ Emitted lines are merged into JSON log rows by ``config.logging_config``.
4
+ Conversation and Claude Code prompts are logged verbatim unless values live under
5
+ sanitized credential keys (e.g. ``api_key``, ``authorization``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import AsyncGenerator, AsyncIterator, Mapping
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+ TRACE_PAYLOAD_BINDING = "trace_payload"
17
+
18
+ _SECRET_VALUE_KEYS = frozenset(
19
+ k.lower()
20
+ for k in (
21
+ "authorization",
22
+ "x-api-key",
23
+ "anthropic-auth-token",
24
+ "api_key",
25
+ "password",
26
+ "secret",
27
+ "token",
28
+ "bearer_token",
29
+ "openapi_token",
30
+ "nvidia-api-key",
31
+ )
32
+ )
33
+
34
+
35
+ def _sanitize_trace_value(obj: Any) -> Any:
36
+ """Recursively copy JSON-like structures redacting credential-shaped keys."""
37
+ if isinstance(obj, Mapping):
38
+ out: dict[str, Any] = {}
39
+ for k, v in obj.items():
40
+ if str(k).lower() in _SECRET_VALUE_KEYS:
41
+ out[str(k)] = "<redacted>"
42
+ else:
43
+ out[str(k)] = _sanitize_trace_value(v)
44
+ return out
45
+ if isinstance(obj, tuple | list):
46
+ return [_sanitize_trace_value(x) for x in obj]
47
+ return obj
48
+
49
+
50
+ def trace_event(*, stage: str, event: str, source: str, **fields: Any) -> None:
51
+ """Emit one structured TRACE row (merged into JSON by the log sink)."""
52
+ payload = _sanitize_trace_value(
53
+ {
54
+ "stage": stage,
55
+ "event": event,
56
+ "source": source,
57
+ **fields,
58
+ },
59
+ )
60
+ logger.bind(trace_payload=payload).info("TRACE {}", event)
61
+
62
+
63
+ def api_messages_request_snapshot(req: Any) -> dict[str, Any]:
64
+ """Return a sanitized snapshot of an Anthropic ``MessagesRequest``-like body."""
65
+ if hasattr(req, "model_dump"):
66
+ data = req.model_dump(mode="python")
67
+ elif isinstance(req, Mapping):
68
+ data = dict(req)
69
+ else:
70
+ data = {}
71
+
72
+ snapshot: dict[str, Any] = {}
73
+ for key in (
74
+ "model",
75
+ "messages",
76
+ "system",
77
+ "tools",
78
+ "tool_choice",
79
+ "max_tokens",
80
+ "thinking",
81
+ "temperature",
82
+ "top_p",
83
+ "top_k",
84
+ "stop_sequences",
85
+ "metadata",
86
+ "stream",
87
+ "thinking_enabled",
88
+ ):
89
+ if key in data and data[key] is not None:
90
+ snapshot[key] = data[key]
91
+ return _sanitize_trace_value(snapshot)
92
+
93
+
94
+ def extract_claude_session_id_from_headers(headers: Mapping[str, str]) -> str | None:
95
+ """Best-effort session id forwarded by Claude Code / SDK via HTTP."""
96
+ lowered = {str(k).lower(): v for k, v in headers.items() if isinstance(v, str)}
97
+ for key in (
98
+ "anthropic-session-id",
99
+ "x-anthropic-session-id",
100
+ "claude-session-id",
101
+ "x-claude-session-id",
102
+ ):
103
+ candidate = lowered.get(key)
104
+ if candidate:
105
+ return candidate
106
+ return None
107
+
108
+
109
+ async def traced_async_stream(
110
+ agen: AsyncIterator[str],
111
+ *,
112
+ stage: str,
113
+ source: str,
114
+ complete_event: str,
115
+ interrupted_event: str,
116
+ chunk_event: str | None = None,
117
+ chunk_interval: int = 250,
118
+ extra: Mapping[str, Any] | None = None,
119
+ ) -> AsyncGenerator[str]:
120
+ """Emit TRACE rows when a text stream completes, fails, cancels, or periodically."""
121
+ common = dict(extra or {})
122
+ count = 0
123
+ nbytes = 0
124
+ interrupted = False
125
+ try:
126
+ async for chunk in agen:
127
+ count += 1
128
+ nbytes += len(chunk.encode("utf-8", errors="replace"))
129
+ if chunk_event and chunk_interval > 0 and count % chunk_interval == 0:
130
+ trace_event(
131
+ stage=stage,
132
+ event=chunk_event,
133
+ source=source,
134
+ stream_chunks_so_far=count,
135
+ stream_bytes_so_far=nbytes,
136
+ **common,
137
+ )
138
+ yield chunk
139
+ except GeneratorExit:
140
+ raise
141
+ except asyncio.CancelledError:
142
+ interrupted = True
143
+ trace_event(
144
+ stage=stage,
145
+ event=interrupted_event,
146
+ source=source,
147
+ stream_chunks=count,
148
+ stream_bytes=nbytes,
149
+ outcome="cancelled",
150
+ **common,
151
+ )
152
+ raise
153
+ except BaseExceptionGroup as grp:
154
+ interrupted = True
155
+ trace_event(
156
+ stage=stage,
157
+ event=interrupted_event,
158
+ source=source,
159
+ stream_chunks=count,
160
+ stream_bytes=nbytes,
161
+ outcome="exception_group",
162
+ note=str(grp),
163
+ **common,
164
+ )
165
+ raise
166
+ except Exception as exc:
167
+ interrupted = True
168
+ trace_event(
169
+ stage=stage,
170
+ event=interrupted_event,
171
+ source=source,
172
+ stream_chunks=count,
173
+ stream_bytes=nbytes,
174
+ outcome="error",
175
+ exc_type=type(exc).__name__,
176
+ **common,
177
+ )
178
+ raise
179
+
180
+ if not interrupted:
181
+ trace_event(
182
+ stage=stage,
183
+ event=complete_event,
184
+ source=source,
185
+ stream_chunks=count,
186
+ stream_bytes=nbytes,
187
+ outcome="ok",
188
+ **common,
189
+ )
190
+
191
+
192
+ def provider_chat_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]:
193
+ """Sanitized OpenAI-compat chat body subset for traces (conversation text verbatim)."""
194
+ keys = ("model", "messages", "tools", "tool_choice", "temperature", "max_tokens")
195
+ snap = {k: body[k] for k in keys if k in body and body[k] is not None}
196
+ return _sanitize_trace_value(snap)
197
+
198
+
199
+ def provider_native_messages_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]:
200
+ """Sanitized Anthropic Messages API body subset for traces."""
201
+ keys = (
202
+ "model",
203
+ "messages",
204
+ "system",
205
+ "tools",
206
+ "tool_choice",
207
+ "max_tokens",
208
+ "thinking",
209
+ "metadata",
210
+ "temperature",
211
+ "top_p",
212
+ "top_k",
213
+ "stop_sequences",
214
+ )
215
+ snap = {k: body[k] for k in keys if k in body and body[k] is not None}
216
+ return _sanitize_trace_value(snap)