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,7 @@
1
+ """NVIDIA NIM provider package."""
2
+
3
+ from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
4
+
5
+ from .client import NvidiaNimProvider
6
+
7
+ __all__ = ["NVIDIA_NIM_DEFAULT_BASE", "NvidiaNimProvider"]
@@ -0,0 +1,91 @@
1
+ """NVIDIA NIM provider implementation."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import openai
7
+ from loguru import logger
8
+
9
+ from config.nim import NimSettings
10
+ from providers.base import ProviderConfig
11
+ from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
12
+ from providers.transports.openai_chat import OpenAIChatTransport
13
+
14
+ from .request import (
15
+ body_without_nim_tool_argument_aliases,
16
+ build_request_body,
17
+ clone_body_without_chat_template,
18
+ clone_body_without_reasoning_budget,
19
+ clone_body_without_reasoning_content,
20
+ nim_tool_argument_aliases_from_body,
21
+ )
22
+
23
+
24
+ class NvidiaNimProvider(OpenAIChatTransport):
25
+ """NVIDIA NIM provider using official OpenAI client."""
26
+
27
+ def __init__(self, config: ProviderConfig, *, nim_settings: NimSettings):
28
+ super().__init__(
29
+ config,
30
+ provider_name="NIM",
31
+ base_url=config.base_url or NVIDIA_NIM_DEFAULT_BASE,
32
+ api_key=config.api_key,
33
+ )
34
+ self._nim_settings = nim_settings
35
+
36
+ def _build_request_body(
37
+ self, request: Any, thinking_enabled: bool | None = None
38
+ ) -> dict:
39
+ """Internal helper for tests and shared building."""
40
+ return build_request_body(
41
+ request,
42
+ self._nim_settings,
43
+ thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
44
+ )
45
+
46
+ def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]:
47
+ """Strip private request metadata before calling NVIDIA NIM."""
48
+ return body_without_nim_tool_argument_aliases(body)
49
+
50
+ def _tool_argument_aliases(self, body: dict[str, Any]) -> dict[str, dict[str, str]]:
51
+ """Return NIM tool argument aliases captured while building this request."""
52
+ return nim_tool_argument_aliases_from_body(body)
53
+
54
+ def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None:
55
+ """Retry once with a downgraded body when NIM rejects a known field."""
56
+ status_code = getattr(error, "status_code", None)
57
+ if not isinstance(error, openai.BadRequestError) and status_code != 400:
58
+ return None
59
+
60
+ error_text = str(error)
61
+ error_body = getattr(error, "body", None)
62
+ if error_body is not None:
63
+ error_text = f"{error_text} {json.dumps(error_body, default=str)}"
64
+ error_text = error_text.lower()
65
+
66
+ if "reasoning_budget" in error_text:
67
+ retry_body = clone_body_without_reasoning_budget(body)
68
+ if retry_body is None:
69
+ return None
70
+ logger.warning(
71
+ "NIM_STREAM: retrying without reasoning_budget after 400 error"
72
+ )
73
+ return retry_body
74
+
75
+ if "chat_template" in error_text:
76
+ retry_body = clone_body_without_chat_template(body)
77
+ if retry_body is None:
78
+ return None
79
+ logger.warning("NIM_STREAM: retrying without chat_template after 400 error")
80
+ return retry_body
81
+
82
+ if "reasoning_content" in error_text:
83
+ retry_body = clone_body_without_reasoning_content(body)
84
+ if retry_body is None:
85
+ return None
86
+ logger.warning(
87
+ "NIM_STREAM: retrying without reasoning_content after 400 error"
88
+ )
89
+ return retry_body
90
+
91
+ return None
@@ -0,0 +1,430 @@
1
+ """Request builder for NVIDIA NIM provider."""
2
+
3
+ from collections.abc import Callable
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from config.nim import NimSettings
10
+ from core.anthropic import (
11
+ ReasoningReplayMode,
12
+ build_base_request_body,
13
+ set_if_not_none,
14
+ )
15
+ from core.anthropic.conversion import OpenAIConversionError
16
+ from providers.exceptions import InvalidRequestError
17
+
18
+ _SCHEMA_VALUE_KEYS = frozenset(
19
+ {
20
+ "additionalProperties",
21
+ "additionalItems",
22
+ "unevaluatedProperties",
23
+ "unevaluatedItems",
24
+ "items",
25
+ "contains",
26
+ "propertyNames",
27
+ "if",
28
+ "then",
29
+ "else",
30
+ "not",
31
+ }
32
+ )
33
+ _SCHEMA_LIST_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"})
34
+ _SCHEMA_MAP_KEYS = frozenset(
35
+ {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"}
36
+ )
37
+ NIM_TOOL_ARGUMENT_ALIASES_KEY = "_fcc_nim_tool_argument_aliases"
38
+ _NIM_TOOL_PARAMETER_ALIAS_PREFIX = "_fcc_arg_"
39
+ _NIM_UNSAFE_TOOL_PARAMETER_NAMES = frozenset({"type"})
40
+
41
+
42
+ def _clone_strip_extra_body(
43
+ body: dict[str, Any],
44
+ strip: Callable[[dict[str, Any]], bool],
45
+ ) -> dict[str, Any] | None:
46
+ """Deep-clone ``body`` and remove fields via ``strip`` on ``extra_body`` only.
47
+
48
+ Returns ``None`` when there is no ``extra_body`` dict or ``strip`` reports no change.
49
+ """
50
+ cloned_body = deepcopy(body)
51
+ extra_body = cloned_body.get("extra_body")
52
+ if not isinstance(extra_body, dict):
53
+ return None
54
+ if not strip(extra_body):
55
+ return None
56
+ if not extra_body:
57
+ cloned_body.pop("extra_body", None)
58
+ return cloned_body
59
+
60
+
61
+ def _strip_reasoning_budget_fields(extra_body: dict[str, Any]) -> bool:
62
+ removed = extra_body.pop("reasoning_budget", None) is not None
63
+ chat_template_kwargs = extra_body.get("chat_template_kwargs")
64
+ if (
65
+ isinstance(chat_template_kwargs, dict)
66
+ and chat_template_kwargs.pop("reasoning_budget", None) is not None
67
+ ):
68
+ removed = True
69
+ return removed
70
+
71
+
72
+ def _strip_chat_template_field(extra_body: dict[str, Any]) -> bool:
73
+ return extra_body.pop("chat_template", None) is not None
74
+
75
+
76
+ def _strip_message_reasoning_content(body: dict[str, Any]) -> bool:
77
+ removed = False
78
+ messages = body.get("messages")
79
+ if not isinstance(messages, list):
80
+ return False
81
+ for message in messages:
82
+ if (
83
+ isinstance(message, dict)
84
+ and message.pop("reasoning_content", None) is not None
85
+ ):
86
+ removed = True
87
+ return removed
88
+
89
+
90
+ def _sanitize_nim_schema_node(value: Any) -> tuple[bool, Any]:
91
+ """Remove boolean JSON Schema subschemas that hosted NIM rejects."""
92
+ if isinstance(value, bool):
93
+ return False, None
94
+ if isinstance(value, dict):
95
+ sanitized: dict[str, Any] = {}
96
+ for key, item in value.items():
97
+ if key in _SCHEMA_VALUE_KEYS:
98
+ keep, sanitized_item = _sanitize_nim_schema_node(item)
99
+ if keep:
100
+ sanitized[key] = sanitized_item
101
+ elif key in _SCHEMA_LIST_KEYS and isinstance(item, list):
102
+ sanitized_items: list[Any] = []
103
+ for schema_item in item:
104
+ keep, sanitized_item = _sanitize_nim_schema_node(schema_item)
105
+ if keep:
106
+ sanitized_items.append(sanitized_item)
107
+ if sanitized_items:
108
+ sanitized[key] = sanitized_items
109
+ elif key in _SCHEMA_MAP_KEYS and isinstance(item, dict):
110
+ sanitized_map: dict[str, Any] = {}
111
+ for map_key, schema_item in item.items():
112
+ keep, sanitized_item = _sanitize_nim_schema_node(schema_item)
113
+ if keep:
114
+ sanitized_map[map_key] = sanitized_item
115
+ sanitized[key] = sanitized_map
116
+ else:
117
+ sanitized[key] = item
118
+ return True, sanitized
119
+ if isinstance(value, list):
120
+ sanitized_items = []
121
+ for item in value:
122
+ keep, sanitized_item = _sanitize_nim_schema_node(item)
123
+ if keep:
124
+ sanitized_items.append(sanitized_item)
125
+ return True, sanitized_items
126
+ return True, value
127
+
128
+
129
+ def _needs_nim_tool_parameter_alias(name: str) -> bool:
130
+ return name in _NIM_UNSAFE_TOOL_PARAMETER_NAMES
131
+
132
+
133
+ def _make_nim_tool_parameter_alias(name: str, reserved: set[str]) -> str:
134
+ safe_tail = "".join(
135
+ character if character.isalnum() or character == "_" else "_"
136
+ for character in name
137
+ ).strip("_")
138
+ if not safe_tail:
139
+ safe_tail = "arg"
140
+ candidate = f"{_NIM_TOOL_PARAMETER_ALIAS_PREFIX}{safe_tail}"
141
+ alias = candidate
142
+ suffix = 2
143
+ while alias in reserved:
144
+ alias = f"{candidate}_{suffix}"
145
+ suffix += 1
146
+ reserved.add(alias)
147
+ return alias
148
+
149
+
150
+ def _collect_nim_tool_property_names(value: Any) -> set[str]:
151
+ names: set[str] = set()
152
+ if isinstance(value, dict):
153
+ properties = value.get("properties")
154
+ if isinstance(properties, dict):
155
+ for property_name, property_schema in properties.items():
156
+ if isinstance(property_name, str):
157
+ names.add(property_name)
158
+ names.update(_collect_nim_tool_property_names(property_schema))
159
+ for key, item in value.items():
160
+ if key != "properties":
161
+ names.update(_collect_nim_tool_property_names(item))
162
+ elif isinstance(value, list):
163
+ for item in value:
164
+ names.update(_collect_nim_tool_property_names(item))
165
+ return names
166
+
167
+
168
+ def _alias_nim_schema_property_names(
169
+ value: Any,
170
+ *,
171
+ reserved: set[str],
172
+ alias_to_original: dict[str, str],
173
+ original_to_alias: dict[str, str],
174
+ ) -> Any:
175
+ if isinstance(value, list):
176
+ return [
177
+ _alias_nim_schema_property_names(
178
+ item,
179
+ reserved=reserved,
180
+ alias_to_original=alias_to_original,
181
+ original_to_alias=original_to_alias,
182
+ )
183
+ for item in value
184
+ ]
185
+ if not isinstance(value, dict):
186
+ return value
187
+
188
+ local_aliases: dict[str, str] = {}
189
+ aliased_value: dict[str, Any] = {}
190
+ properties = value.get("properties")
191
+ if isinstance(properties, dict):
192
+ aliased_properties: dict[str, Any] = {}
193
+ for property_name, property_schema in properties.items():
194
+ aliased_schema = _alias_nim_schema_property_names(
195
+ property_schema,
196
+ reserved=reserved,
197
+ alias_to_original=alias_to_original,
198
+ original_to_alias=original_to_alias,
199
+ )
200
+ if isinstance(property_name, str) and _needs_nim_tool_parameter_alias(
201
+ property_name
202
+ ):
203
+ alias = original_to_alias.get(property_name)
204
+ if alias is None:
205
+ alias = _make_nim_tool_parameter_alias(property_name, reserved)
206
+ alias_to_original[alias] = property_name
207
+ original_to_alias[property_name] = alias
208
+ local_aliases[property_name] = alias
209
+ aliased_properties[alias] = aliased_schema
210
+ else:
211
+ aliased_properties[property_name] = aliased_schema
212
+ aliased_value["properties"] = aliased_properties
213
+
214
+ for key, item in value.items():
215
+ if key == "properties":
216
+ continue
217
+ if key == "required" and isinstance(item, list):
218
+ aliased_value[key] = [
219
+ local_aliases.get(required_item, required_item)
220
+ if isinstance(required_item, str)
221
+ else required_item
222
+ for required_item in item
223
+ ]
224
+ continue
225
+ aliased_value[key] = _alias_nim_schema_property_names(
226
+ item,
227
+ reserved=reserved,
228
+ alias_to_original=alias_to_original,
229
+ original_to_alias=original_to_alias,
230
+ )
231
+ return aliased_value
232
+
233
+
234
+ def _alias_nim_tool_parameters(
235
+ parameters: dict[str, Any],
236
+ ) -> tuple[dict[str, Any], dict[str, str]]:
237
+ alias_to_original: dict[str, str] = {}
238
+ original_to_alias: dict[str, str] = {}
239
+ reserved = _collect_nim_tool_property_names(parameters)
240
+ aliased_parameters = _alias_nim_schema_property_names(
241
+ parameters,
242
+ reserved=reserved,
243
+ alias_to_original=alias_to_original,
244
+ original_to_alias=original_to_alias,
245
+ )
246
+ if not alias_to_original:
247
+ return parameters, {}
248
+ return aliased_parameters, alias_to_original
249
+
250
+
251
+ def _sanitize_nim_tool_schemas(body: dict[str, Any]) -> None:
252
+ """Sanitize only tool parameter schemas, preserving tool calls/history."""
253
+ tools = body.get("tools")
254
+ if not isinstance(tools, list):
255
+ return
256
+
257
+ tool_argument_aliases: dict[str, dict[str, str]] = {}
258
+ sanitized_tools: list[Any] = []
259
+ for tool in tools:
260
+ if not isinstance(tool, dict):
261
+ sanitized_tools.append(tool)
262
+ continue
263
+ sanitized_tool = dict(tool)
264
+ function = tool.get("function")
265
+ if isinstance(function, dict):
266
+ sanitized_function = dict(function)
267
+ parameters = function.get("parameters")
268
+ if isinstance(parameters, dict):
269
+ _, sanitized_parameters = _sanitize_nim_schema_node(parameters)
270
+ sanitized_parameters, argument_aliases = _alias_nim_tool_parameters(
271
+ sanitized_parameters
272
+ )
273
+ sanitized_function["parameters"] = sanitized_parameters
274
+ tool_name = function.get("name")
275
+ if argument_aliases and isinstance(tool_name, str) and tool_name:
276
+ tool_argument_aliases[tool_name] = argument_aliases
277
+ sanitized_tool["function"] = sanitized_function
278
+ sanitized_tools.append(sanitized_tool)
279
+
280
+ body["tools"] = sanitized_tools
281
+ if tool_argument_aliases:
282
+ body[NIM_TOOL_ARGUMENT_ALIASES_KEY] = tool_argument_aliases
283
+ else:
284
+ body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
285
+
286
+
287
+ def nim_tool_argument_aliases_from_body(
288
+ body: dict[str, Any],
289
+ ) -> dict[str, dict[str, str]]:
290
+ """Return validated private NIM tool argument aliases from a built body."""
291
+ raw_aliases = body.get(NIM_TOOL_ARGUMENT_ALIASES_KEY)
292
+ if not isinstance(raw_aliases, dict):
293
+ return {}
294
+
295
+ aliases: dict[str, dict[str, str]] = {}
296
+ for tool_name, tool_aliases in raw_aliases.items():
297
+ if not isinstance(tool_name, str) or not isinstance(tool_aliases, dict):
298
+ continue
299
+ sanitized_aliases = {
300
+ alias: original
301
+ for alias, original in tool_aliases.items()
302
+ if isinstance(alias, str) and isinstance(original, str)
303
+ }
304
+ if sanitized_aliases:
305
+ aliases[tool_name] = sanitized_aliases
306
+ return aliases
307
+
308
+
309
+ def body_without_nim_tool_argument_aliases(body: dict[str, Any]) -> dict[str, Any]:
310
+ """Return a request body with private alias metadata stripped before upstream I/O."""
311
+ if NIM_TOOL_ARGUMENT_ALIASES_KEY not in body:
312
+ return body
313
+ upstream_body = dict(body)
314
+ upstream_body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None)
315
+ return upstream_body
316
+
317
+
318
+ def _set_extra(
319
+ extra_body: dict[str, Any], key: str, value: Any, ignore_value: Any = None
320
+ ) -> None:
321
+ if key in extra_body:
322
+ return
323
+ if value is None:
324
+ return
325
+ if ignore_value is not None and value == ignore_value:
326
+ return
327
+ extra_body[key] = value
328
+
329
+
330
+ def clone_body_without_reasoning_budget(body: dict[str, Any]) -> dict[str, Any] | None:
331
+ """Clone a request body and strip only reasoning_budget fields."""
332
+ return _clone_strip_extra_body(body, _strip_reasoning_budget_fields)
333
+
334
+
335
+ def clone_body_without_chat_template(body: dict[str, Any]) -> dict[str, Any] | None:
336
+ """Clone a request body and strip only chat_template."""
337
+ return _clone_strip_extra_body(body, _strip_chat_template_field)
338
+
339
+
340
+ def clone_body_without_reasoning_content(body: dict[str, Any]) -> dict[str, Any] | None:
341
+ """Clone a request body and strip assistant message ``reasoning_content`` fields."""
342
+ cloned_body = deepcopy(body)
343
+ if not _strip_message_reasoning_content(cloned_body):
344
+ return None
345
+ return cloned_body
346
+
347
+
348
+ def build_request_body(
349
+ request_data: Any, nim: NimSettings, *, thinking_enabled: bool
350
+ ) -> dict:
351
+ """Build OpenAI-format request body from Anthropic request."""
352
+ logger.debug(
353
+ "NIM_REQUEST: conversion start model={} msgs={}",
354
+ getattr(request_data, "model", "?"),
355
+ len(getattr(request_data, "messages", [])),
356
+ )
357
+ try:
358
+ body = build_base_request_body(
359
+ request_data,
360
+ reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
361
+ if thinking_enabled
362
+ else ReasoningReplayMode.DISABLED,
363
+ )
364
+ except OpenAIConversionError as exc:
365
+ raise InvalidRequestError(str(exc)) from exc
366
+
367
+ _sanitize_nim_tool_schemas(body)
368
+
369
+ # NIM-specific max_tokens: cap against nim.max_tokens
370
+ max_tokens = body.get("max_tokens") or getattr(request_data, "max_tokens", None)
371
+ if max_tokens is None:
372
+ max_tokens = nim.max_tokens
373
+ elif nim.max_tokens:
374
+ max_tokens = min(max_tokens, nim.max_tokens)
375
+ set_if_not_none(body, "max_tokens", max_tokens)
376
+
377
+ # NIM-specific temperature/top_p: fall back to NIM defaults if request didn't set
378
+ if body.get("temperature") is None and nim.temperature is not None:
379
+ body["temperature"] = nim.temperature
380
+ if body.get("top_p") is None and nim.top_p is not None:
381
+ body["top_p"] = nim.top_p
382
+
383
+ # NIM-specific stop sequences fallback
384
+ if "stop" not in body and nim.stop:
385
+ body["stop"] = nim.stop
386
+
387
+ if nim.presence_penalty != 0.0:
388
+ body["presence_penalty"] = nim.presence_penalty
389
+ if nim.frequency_penalty != 0.0:
390
+ body["frequency_penalty"] = nim.frequency_penalty
391
+ if nim.seed is not None:
392
+ body["seed"] = nim.seed
393
+
394
+ body["parallel_tool_calls"] = nim.parallel_tool_calls
395
+
396
+ # Handle non-standard parameters via extra_body
397
+ extra_body: dict[str, Any] = {}
398
+ request_extra = getattr(request_data, "extra_body", None)
399
+ if request_extra:
400
+ extra_body.update(request_extra)
401
+
402
+ if thinking_enabled:
403
+ chat_template_kwargs = extra_body.setdefault(
404
+ "chat_template_kwargs", {"thinking": True, "enable_thinking": True}
405
+ )
406
+ if isinstance(chat_template_kwargs, dict):
407
+ chat_template_kwargs.setdefault("reasoning_budget", max_tokens)
408
+
409
+ req_top_k = getattr(request_data, "top_k", None)
410
+ top_k = req_top_k if req_top_k is not None else nim.top_k
411
+ _set_extra(extra_body, "top_k", top_k, ignore_value=-1)
412
+ _set_extra(extra_body, "min_p", nim.min_p, ignore_value=0.0)
413
+ _set_extra(
414
+ extra_body, "repetition_penalty", nim.repetition_penalty, ignore_value=1.0
415
+ )
416
+ _set_extra(extra_body, "min_tokens", nim.min_tokens, ignore_value=0)
417
+ _set_extra(extra_body, "chat_template", nim.chat_template)
418
+ _set_extra(extra_body, "request_id", nim.request_id)
419
+ _set_extra(extra_body, "ignore_eos", nim.ignore_eos)
420
+
421
+ if extra_body:
422
+ body["extra_body"] = extra_body
423
+
424
+ logger.debug(
425
+ "NIM_REQUEST: conversion done model={} msgs={} tools={}",
426
+ body.get("model"),
427
+ len(body.get("messages", [])),
428
+ len(body.get("tools", [])),
429
+ )
430
+ return body
@@ -0,0 +1,95 @@
1
+ """NVIDIA NIM / Riva offline ASR for voice notes (provider-owned transport)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from loguru import logger
8
+
9
+ # NVIDIA NIM Whisper model mapping: (function_id, language_code)
10
+ _NIM_ASR_MODEL_MAP: dict[str, tuple[str, str]] = {
11
+ "nvidia/parakeet-ctc-0.6b-zh-tw": ("8473f56d-51ef-473c-bb26-efd4f5def2bf", "zh-TW"),
12
+ "nvidia/parakeet-ctc-0.6b-zh-cn": ("9add5ef7-322e-47e0-ad7a-5653fb8d259b", "zh-CN"),
13
+ # function-id from NVIDIA NIM API docs (parakeet-ctc-0.6b-es).
14
+ "nvidia/parakeet-ctc-0.6b-es": ("a9eeee8f-b509-4712-b19d-194361fa5f31", "es-US"),
15
+ "nvidia/parakeet-ctc-0.6b-vi": ("f3dff2bb-99f9-403d-a5f1-f574a757deb0", "vi-VN"),
16
+ "nvidia/parakeet-ctc-1.1b-asr": ("1598d209-5e27-4d3c-8079-4751568b1081", "en-US"),
17
+ "nvidia/parakeet-ctc-0.6b-asr": ("d8dd4e9b-fbf5-4fb0-9dba-8cf436c8d965", "en-US"),
18
+ "nvidia/parakeet-1.1b-rnnt-multilingual-asr": (
19
+ "71203149-d3b7-4460-8231-1be2543a1fca",
20
+ "",
21
+ ),
22
+ "openai/whisper-large-v3": ("b702f636-f60c-4a3d-a6f4-f3568c13bd7d", "multi"),
23
+ }
24
+
25
+ _RIVA_SERVER = "grpc.nvcf.nvidia.com:443"
26
+
27
+
28
+ def transcribe_audio_file(
29
+ file_path: Path,
30
+ model: str,
31
+ *,
32
+ api_key: str,
33
+ ) -> str:
34
+ """Transcribe audio using NVIDIA NIM / Riva gRPC (offline recognition).
35
+
36
+ Args:
37
+ file_path: Path to encoded audio bytes readable by Riva.
38
+ model: Hugging Face-style NIM model id (see ``_NIM_ASR_MODEL_MAP``).
39
+ api_key: NVIDIA API key (Bearer token); must be non-empty.
40
+
41
+ Returns:
42
+ Transcript text, or ``(no speech detected)`` when empty.
43
+ """
44
+ key = (api_key or "").strip()
45
+ if not key:
46
+ raise ValueError(
47
+ "NVIDIA NIM transcription requires a non-empty nvidia_nim_api_key "
48
+ "(configure NVIDIA_NIM_API_KEY or pass api_key explicitly)."
49
+ )
50
+
51
+ try:
52
+ import riva.client
53
+ except ImportError as e:
54
+ raise ImportError(
55
+ "NVIDIA NIM transcription requires the voice extra. "
56
+ "Install with: uv sync --extra voice"
57
+ ) from e
58
+
59
+ model_config = _NIM_ASR_MODEL_MAP.get(model)
60
+ if not model_config:
61
+ raise ValueError(
62
+ f"No NVIDIA NIM config found for model: {model}. "
63
+ f"Supported models: {', '.join(_NIM_ASR_MODEL_MAP.keys())}"
64
+ )
65
+ function_id, language_code = model_config
66
+
67
+ auth = riva.client.Auth(
68
+ use_ssl=True,
69
+ uri=_RIVA_SERVER,
70
+ metadata_args=[
71
+ ["function-id", function_id],
72
+ ["authorization", f"Bearer {key}"],
73
+ ],
74
+ )
75
+
76
+ asr_service = riva.client.ASRService(auth)
77
+
78
+ config = riva.client.RecognitionConfig(
79
+ language_code=language_code,
80
+ max_alternatives=1,
81
+ verbatim_transcripts=True,
82
+ )
83
+
84
+ with open(file_path, "rb") as f:
85
+ data = f.read()
86
+
87
+ response = asr_service.offline_recognize(data, config)
88
+
89
+ transcript = ""
90
+ results = getattr(response, "results", None)
91
+ if results and results[0].alternatives:
92
+ transcript = results[0].alternatives[0].transcript
93
+
94
+ logger.debug(f"NIM transcription: {len(transcript)} chars")
95
+ return transcript or "(no speech detected)"
@@ -0,0 +1,7 @@
1
+ """Ollama provider package."""
2
+
3
+ from providers.defaults import OLLAMA_DEFAULT_BASE
4
+
5
+ from .client import OllamaProvider
6
+
7
+ __all__ = ["OLLAMA_DEFAULT_BASE", "OllamaProvider"]
@@ -0,0 +1,39 @@
1
+ """Ollama provider implementation."""
2
+
3
+ import httpx
4
+
5
+ from providers.base import ProviderConfig
6
+ from providers.defaults import OLLAMA_DEFAULT_BASE
7
+ from providers.model_listing import extract_ollama_model_ids
8
+ from providers.transports.anthropic_messages import AnthropicMessagesTransport
9
+
10
+
11
+ class OllamaProvider(AnthropicMessagesTransport):
12
+ """Ollama provider using native Anthropic Messages API."""
13
+
14
+ def __init__(self, config: ProviderConfig):
15
+ super().__init__(
16
+ config,
17
+ provider_name="OLLAMA",
18
+ default_base_url=OLLAMA_DEFAULT_BASE,
19
+ )
20
+ self._api_key = config.api_key or "ollama"
21
+
22
+ async def _send_stream_request(self, body: dict) -> httpx.Response:
23
+ """Create a streaming native Anthropic messages response."""
24
+ request = self._client.build_request(
25
+ "POST",
26
+ "/v1/messages",
27
+ json=body,
28
+ headers=self._request_headers(),
29
+ )
30
+ return await self._client.send(request, stream=True)
31
+
32
+ async def _send_model_list_request(self) -> httpx.Response:
33
+ """Query Ollama's native local model-list endpoint."""
34
+ return await self._client.get(f"{self._base_url}/api/tags")
35
+
36
+ def _extract_model_ids_from_model_list_payload(
37
+ self, payload: object
38
+ ) -> frozenset[str]:
39
+ return extract_ollama_model_ids(payload, provider_name=self._provider_name)
@@ -0,0 +1,7 @@
1
+ """OpenRouter provider - Anthropic-compatible native transport."""
2
+
3
+ from providers.defaults import OPENROUTER_DEFAULT_BASE
4
+
5
+ from .client import OpenRouterProvider
6
+
7
+ __all__ = ["OPENROUTER_DEFAULT_BASE", "OpenRouterProvider"]