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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- 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)
|