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,199 @@
1
+ """Request builder for Google Gemini API (AI Studio OpenAI-compatible chat completions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any, cast
7
+
8
+ from loguru import logger
9
+
10
+ from core.anthropic import ReasoningReplayMode, build_base_request_body
11
+ from core.anthropic.conversion import OpenAIConversionError
12
+ from providers.exceptions import InvalidRequestError
13
+
14
+ GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
15
+
16
+
17
+ def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]:
18
+ value = container.get(key)
19
+ if isinstance(value, dict):
20
+ return cast(dict[str, Any], value)
21
+ nested: dict[str, Any] = {}
22
+ container[key] = nested
23
+ return nested
24
+
25
+
26
+ def _apply_thinking_config(extra_body: dict[str, Any]) -> None:
27
+ # OpenAI's SDK merges its ``extra_body`` argument into the request JSON.
28
+ # Google expects its extension fields under a literal JSON ``extra_body`` key.
29
+ literal_extra_body = _ensure_dict(extra_body, "extra_body")
30
+ google_section = _ensure_dict(literal_extra_body, "google")
31
+ thinking_cfg = _ensure_dict(google_section, "thinking_config")
32
+ thinking_cfg.setdefault("include_thoughts", True)
33
+
34
+
35
+ def _is_gemini_3_model(model: Any) -> bool:
36
+ return "gemini-3" in str(model).lower()
37
+
38
+
39
+ def _thought_signature_from_extra_content(extra_content: Any) -> str | None:
40
+ if not isinstance(extra_content, dict):
41
+ return None
42
+ google = extra_content.get("google")
43
+ if not isinstance(google, dict):
44
+ return None
45
+ signature = google.get("thought_signature")
46
+ return signature if isinstance(signature, str) and signature else None
47
+
48
+
49
+ def _tool_call_thought_signature(tool_call: dict[str, Any]) -> str | None:
50
+ return _thought_signature_from_extra_content(tool_call.get("extra_content"))
51
+
52
+
53
+ def _set_tool_call_thought_signature(tool_call: dict[str, Any], signature: str) -> None:
54
+ extra_content = tool_call.get("extra_content")
55
+ if not isinstance(extra_content, dict):
56
+ extra_content = {}
57
+ tool_call["extra_content"] = extra_content
58
+ google = extra_content.get("google")
59
+ if not isinstance(google, dict):
60
+ google = {}
61
+ extra_content["google"] = google
62
+ google["thought_signature"] = signature
63
+
64
+
65
+ def _message_has_standard_user_content(message: dict[str, Any]) -> bool:
66
+ if message.get("role") != "user":
67
+ return False
68
+ content = message.get("content")
69
+ if isinstance(content, str):
70
+ return bool(content.strip())
71
+ if isinstance(content, list):
72
+ return any(
73
+ isinstance(part, dict)
74
+ and isinstance(part.get("text"), str)
75
+ and bool(part["text"].strip())
76
+ for part in content
77
+ )
78
+ return False
79
+
80
+
81
+ def _current_turn_start_index(messages: list[Any]) -> int:
82
+ for index in range(len(messages) - 1, -1, -1):
83
+ message = messages[index]
84
+ if isinstance(message, dict) and _message_has_standard_user_content(message):
85
+ return index
86
+ return -1
87
+
88
+
89
+ def _apply_cached_tool_call_signatures(
90
+ messages: list[Any], tool_call_extra_content_by_id: dict[str, dict[str, Any]]
91
+ ) -> None:
92
+ if not tool_call_extra_content_by_id:
93
+ return
94
+ for message in messages:
95
+ if not isinstance(message, dict) or message.get("role") != "assistant":
96
+ continue
97
+ tool_calls = message.get("tool_calls")
98
+ if not isinstance(tool_calls, list):
99
+ continue
100
+ for tool_call in tool_calls:
101
+ if not isinstance(tool_call, dict) or _tool_call_thought_signature(
102
+ tool_call
103
+ ):
104
+ continue
105
+ tool_call_id = tool_call.get("id")
106
+ if tool_call_id is None:
107
+ continue
108
+ cached_extra_content = tool_call_extra_content_by_id.get(str(tool_call_id))
109
+ if not cached_extra_content:
110
+ continue
111
+ cached_signature = _thought_signature_from_extra_content(
112
+ cached_extra_content
113
+ )
114
+ if cached_signature:
115
+ tool_call["extra_content"] = deepcopy(cached_extra_content)
116
+
117
+
118
+ def _apply_gemini_3_missing_current_turn_signatures(
119
+ body: dict[str, Any], messages: list[Any]
120
+ ) -> None:
121
+ if not _is_gemini_3_model(body.get("model")):
122
+ return
123
+
124
+ start_index = _current_turn_start_index(messages)
125
+ for message in messages[start_index + 1 :]:
126
+ if not isinstance(message, dict) or message.get("role") != "assistant":
127
+ continue
128
+ tool_calls = message.get("tool_calls")
129
+ if not isinstance(tool_calls, list) or not tool_calls:
130
+ continue
131
+ first_tool_call = tool_calls[0]
132
+ if not isinstance(first_tool_call, dict):
133
+ continue
134
+ if _tool_call_thought_signature(first_tool_call):
135
+ continue
136
+ _set_tool_call_thought_signature(
137
+ first_tool_call, GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR
138
+ )
139
+
140
+
141
+ def _apply_gemini_tool_call_signatures(
142
+ body: dict[str, Any],
143
+ *,
144
+ tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None,
145
+ ) -> None:
146
+ messages = body.get("messages")
147
+ if not isinstance(messages, list):
148
+ return
149
+ _apply_cached_tool_call_signatures(messages, tool_call_extra_content_by_id or {})
150
+ _apply_gemini_3_missing_current_turn_signatures(body, messages)
151
+
152
+
153
+ def build_request_body(
154
+ request_data: Any,
155
+ *,
156
+ thinking_enabled: bool,
157
+ tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None,
158
+ ) -> dict:
159
+ """Build OpenAI-format request body from an Anthropic request for Gemini."""
160
+ logger.debug(
161
+ "GEMINI_REQUEST: conversion start model={} msgs={}",
162
+ getattr(request_data, "model", "?"),
163
+ len(getattr(request_data, "messages", [])),
164
+ )
165
+ try:
166
+ body = build_base_request_body(
167
+ request_data,
168
+ reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
169
+ if thinking_enabled
170
+ else ReasoningReplayMode.DISABLED,
171
+ )
172
+ except OpenAIConversionError as exc:
173
+ raise InvalidRequestError(str(exc)) from exc
174
+
175
+ extra_body: dict[str, Any] = {}
176
+ request_extra = getattr(request_data, "extra_body", None)
177
+ if isinstance(request_extra, dict):
178
+ extra_body.update(deepcopy(request_extra))
179
+
180
+ if thinking_enabled:
181
+ _apply_thinking_config(extra_body)
182
+ else:
183
+ body["reasoning_effort"] = "none"
184
+
185
+ if extra_body:
186
+ body["extra_body"] = extra_body
187
+
188
+ _apply_gemini_tool_call_signatures(
189
+ body,
190
+ tool_call_extra_content_by_id=tool_call_extra_content_by_id,
191
+ )
192
+
193
+ logger.debug(
194
+ "GEMINI_REQUEST: conversion done model={} msgs={} tools={}",
195
+ body.get("model"),
196
+ len(body.get("messages", [])),
197
+ len(body.get("tools", [])),
198
+ )
199
+ return body
@@ -0,0 +1,7 @@
1
+ """Groq Cloud (OpenAI-compat) adapter."""
2
+
3
+ from providers.defaults import GROQ_DEFAULT_BASE
4
+
5
+ from .client import GroqProvider
6
+
7
+ __all__ = ["GROQ_DEFAULT_BASE", "GroqProvider"]
@@ -0,0 +1,31 @@
1
+ """Groq provider implementation (OpenAI-compatible chat completions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from providers.base import ProviderConfig
8
+ from providers.defaults import GROQ_DEFAULT_BASE
9
+ from providers.transports.openai_chat import OpenAIChatTransport
10
+
11
+ from .request import build_request_body
12
+
13
+
14
+ class GroqProvider(OpenAIChatTransport):
15
+ """Groq API using ``https://api.groq.com/openai/v1/chat/completions``."""
16
+
17
+ def __init__(self, config: ProviderConfig):
18
+ super().__init__(
19
+ config,
20
+ provider_name="GROQ",
21
+ base_url=config.base_url or GROQ_DEFAULT_BASE,
22
+ api_key=config.api_key,
23
+ )
24
+
25
+ def _build_request_body(
26
+ self, request: Any, thinking_enabled: bool | None = None
27
+ ) -> dict:
28
+ return build_request_body(
29
+ request,
30
+ thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
31
+ )
@@ -0,0 +1,83 @@
1
+ """Request builder for Groq (OpenAI-compatible chat completions).
2
+
3
+ See Groq docs: https://console.groq.com/docs/openai — ``messages[].name`` and
4
+ unsupported token fields yield 400; ``max_completion_tokens`` is preferred over
5
+ deprecated ``max_tokens``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from core.anthropic import ReasoningReplayMode, build_base_request_body
15
+ from core.anthropic.conversion import OpenAIConversionError
16
+ from providers.exceptions import InvalidRequestError
17
+
18
+ _GROQ_UNSUPPORTED_TOP_KEYS = frozenset({"logprobs", "logit_bias", "top_logprobs"})
19
+
20
+
21
+ def _strip_message_names(messages: Any) -> None:
22
+ """Remove ``name`` from each chat message (Groq rejects ``messages[].name``)."""
23
+ if not isinstance(messages, list):
24
+ return
25
+ for msg in messages:
26
+ if isinstance(msg, dict):
27
+ msg.pop("name", None)
28
+
29
+
30
+ def _strip_unsupported_body_keys(body: dict[str, Any]) -> None:
31
+ for key in _GROQ_UNSUPPORTED_TOP_KEYS:
32
+ body.pop(key, None)
33
+
34
+
35
+ def _normalize_max_completion_tokens(body: dict[str, Any]) -> None:
36
+ if "max_completion_tokens" in body:
37
+ body.pop("max_tokens", None)
38
+ return
39
+ if "max_tokens" in body and body["max_tokens"] is not None:
40
+ body["max_completion_tokens"] = body.pop("max_tokens")
41
+
42
+
43
+ def _normalize_n_candidates(body: dict[str, Any]) -> None:
44
+ """Groq only supports ``n`` = 1; coerce if present."""
45
+ if body.get("n") is None:
46
+ return
47
+ body["n"] = 1
48
+
49
+
50
+ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
51
+ """Build OpenAI-format request body from an Anthropic request for Groq."""
52
+ logger.debug(
53
+ "GROQ_REQUEST: conversion start model={} msgs={}",
54
+ getattr(request_data, "model", "?"),
55
+ len(getattr(request_data, "messages", [])),
56
+ )
57
+ try:
58
+ body = build_base_request_body(
59
+ request_data,
60
+ reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
61
+ if thinking_enabled
62
+ else ReasoningReplayMode.DISABLED,
63
+ )
64
+ except OpenAIConversionError as exc:
65
+ raise InvalidRequestError(str(exc)) from exc
66
+
67
+ request_extra = getattr(request_data, "extra_body", None)
68
+ if isinstance(request_extra, dict) and request_extra:
69
+ merged = dict(request_extra)
70
+ body["extra_body"] = merged
71
+
72
+ _strip_message_names(body.get("messages"))
73
+ _strip_unsupported_body_keys(body)
74
+ _normalize_max_completion_tokens(body)
75
+ _normalize_n_candidates(body)
76
+
77
+ logger.debug(
78
+ "GROQ_REQUEST: conversion done model={} msgs={} tools={}",
79
+ body.get("model"),
80
+ len(body.get("messages", [])),
81
+ len(body.get("tools", [])),
82
+ )
83
+ return body
@@ -0,0 +1,10 @@
1
+ """Kimi (Moonshot) provider exports."""
2
+
3
+ from providers.defaults import KIMI_DEFAULT_BASE
4
+
5
+ from .client import KimiProvider
6
+
7
+ __all__ = [
8
+ "KIMI_DEFAULT_BASE",
9
+ "KimiProvider",
10
+ ]
@@ -0,0 +1,53 @@
1
+ """Kimi (Moonshot) provider using native Anthropic-compatible Messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from providers.base import ProviderConfig
10
+ from providers.defaults import KIMI_DEFAULT_BASE
11
+ from providers.transports.anthropic_messages import AnthropicMessagesTransport
12
+
13
+ from .request import build_request_body
14
+
15
+ _MOONSHOT_OPENAI_MODELS_URL = "https://api.moonshot.ai/v1/models"
16
+ _ANTHROPIC_VERSION = "2023-06-01"
17
+
18
+
19
+ class KimiProvider(AnthropicMessagesTransport):
20
+ """Kimi provider using Anthropic-compatible Messages at api.moonshot.ai/anthropic/v1."""
21
+
22
+ def __init__(self, config: ProviderConfig):
23
+ super().__init__(
24
+ config,
25
+ provider_name="KIMI",
26
+ default_base_url=KIMI_DEFAULT_BASE,
27
+ )
28
+
29
+ def _build_request_body(
30
+ self, request: Any, thinking_enabled: bool | None = None
31
+ ) -> dict:
32
+ return build_request_body(
33
+ request,
34
+ thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
35
+ )
36
+
37
+ def _request_headers(self) -> dict[str, str]:
38
+ return {
39
+ "Accept": "text/event-stream",
40
+ "Authorization": f"Bearer {self._api_key}",
41
+ "Content-Type": "application/json",
42
+ "anthropic-version": _ANTHROPIC_VERSION,
43
+ }
44
+
45
+ async def _send_model_list_request(self) -> httpx.Response:
46
+ """Models are listed from the OpenAI-compat root, not ``/anthropic/v1``."""
47
+ return await self._client.get(
48
+ _MOONSHOT_OPENAI_MODELS_URL,
49
+ headers=self._model_list_headers(),
50
+ )
51
+
52
+ def _model_list_headers(self) -> dict[str, str]:
53
+ return {"Authorization": f"Bearer {self._api_key}"}
@@ -0,0 +1,42 @@
1
+ """Native Anthropic Messages request builder for Kimi (Moonshot)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
10
+ from core.anthropic.native_messages_request import (
11
+ build_base_native_anthropic_request_body,
12
+ )
13
+ from providers.exceptions import InvalidRequestError
14
+
15
+
16
+ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
17
+ """Build JSON for Kimi Anthropic-compat ``POST …/messages``."""
18
+ logger.debug(
19
+ "KIMI_REQUEST: native build model={} msgs={}",
20
+ getattr(request_data, "model", "?"),
21
+ len(getattr(request_data, "messages", [])),
22
+ )
23
+
24
+ body = build_base_native_anthropic_request_body(
25
+ request_data,
26
+ default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
27
+ thinking_enabled=thinking_enabled,
28
+ )
29
+ extra = getattr(request_data, "extra_body", None)
30
+ if extra:
31
+ raise InvalidRequestError(
32
+ "Kimi native Messages API does not support extra_body on requests."
33
+ )
34
+ body["stream"] = True
35
+
36
+ logger.debug(
37
+ "KIMI_REQUEST: build done model={} msgs={} tools={}",
38
+ body.get("model"),
39
+ len(body.get("messages", [])),
40
+ len(body.get("tools", [])),
41
+ )
42
+ return body
@@ -0,0 +1,3 @@
1
+ from .client import LlamaCppProvider
2
+
3
+ __all__ = ["LlamaCppProvider"]
@@ -0,0 +1,16 @@
1
+ """Llama.cpp provider implementation."""
2
+
3
+ from providers.base import ProviderConfig
4
+ from providers.defaults import LLAMACPP_DEFAULT_BASE
5
+ from providers.transports.anthropic_messages import AnthropicMessagesTransport
6
+
7
+
8
+ class LlamaCppProvider(AnthropicMessagesTransport):
9
+ """Llama.cpp provider using native Anthropic Messages endpoint."""
10
+
11
+ def __init__(self, config: ProviderConfig):
12
+ super().__init__(
13
+ config,
14
+ provider_name="LLAMACPP",
15
+ default_base_url=LLAMACPP_DEFAULT_BASE,
16
+ )
@@ -0,0 +1,5 @@
1
+ """LM Studio provider - Anthropic-compatible local API."""
2
+
3
+ from .client import LMStudioProvider
4
+
5
+ __all__ = ["LMStudioProvider"]
@@ -0,0 +1,16 @@
1
+ """LM Studio provider implementation."""
2
+
3
+ from providers.base import ProviderConfig
4
+ from providers.defaults import LMSTUDIO_DEFAULT_BASE
5
+ from providers.transports.anthropic_messages import AnthropicMessagesTransport
6
+
7
+
8
+ class LMStudioProvider(AnthropicMessagesTransport):
9
+ """LM Studio provider using native Anthropic Messages endpoint."""
10
+
11
+ def __init__(self, config: ProviderConfig):
12
+ super().__init__(
13
+ config,
14
+ provider_name="LMSTUDIO",
15
+ default_base_url=LMSTUDIO_DEFAULT_BASE,
16
+ )
@@ -0,0 +1,7 @@
1
+ """Mistral La Plateforme provider exports."""
2
+
3
+ from providers.defaults import MISTRAL_DEFAULT_BASE
4
+
5
+ from .client import MistralProvider
6
+
7
+ __all__ = ["MISTRAL_DEFAULT_BASE", "MistralProvider"]
@@ -0,0 +1,31 @@
1
+ """Mistral La Plateforme provider implementation (OpenAI-compatible chat completions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from providers.base import ProviderConfig
8
+ from providers.defaults import MISTRAL_DEFAULT_BASE
9
+ from providers.transports.openai_chat import OpenAIChatTransport
10
+
11
+ from .request import build_request_body
12
+
13
+
14
+ class MistralProvider(OpenAIChatTransport):
15
+ """Mistral API using ``https://api.mistral.ai/v1/chat/completions``."""
16
+
17
+ def __init__(self, config: ProviderConfig):
18
+ super().__init__(
19
+ config,
20
+ provider_name="MISTRAL",
21
+ base_url=config.base_url or MISTRAL_DEFAULT_BASE,
22
+ api_key=config.api_key,
23
+ )
24
+
25
+ def _build_request_body(
26
+ self, request: Any, thinking_enabled: bool | None = None
27
+ ) -> dict:
28
+ return build_request_body(
29
+ request,
30
+ thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
31
+ )
@@ -0,0 +1,37 @@
1
+ """Request builder for Mistral La Plateforme (OpenAI-compatible chat completions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+
9
+ from core.anthropic import ReasoningReplayMode, build_base_request_body
10
+ from core.anthropic.conversion import OpenAIConversionError
11
+ from providers.exceptions import InvalidRequestError
12
+
13
+
14
+ def build_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
15
+ """Build OpenAI-format request body from Anthropic request for Mistral."""
16
+ logger.debug(
17
+ "MISTRAL_REQUEST: conversion start model={} msgs={}",
18
+ getattr(request_data, "model", "?"),
19
+ len(getattr(request_data, "messages", [])),
20
+ )
21
+ try:
22
+ body = build_base_request_body(
23
+ request_data,
24
+ reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
25
+ if thinking_enabled
26
+ else ReasoningReplayMode.DISABLED,
27
+ )
28
+ except OpenAIConversionError as exc:
29
+ raise InvalidRequestError(str(exc)) from exc
30
+
31
+ logger.debug(
32
+ "MISTRAL_REQUEST: conversion done model={} msgs={} tools={}",
33
+ body.get("model"),
34
+ len(body.get("messages", [])),
35
+ len(body.get("tools", [])),
36
+ )
37
+ return body
@@ -0,0 +1,133 @@
1
+ """Provider model-list response parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from providers.exceptions import ModelListResponseError
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ProviderModelInfo:
14
+ """Internal provider model metadata used for gateway model-list shaping."""
15
+
16
+ model_id: str
17
+ supports_thinking: bool | None = None
18
+
19
+
20
+ def model_infos_from_ids(
21
+ model_ids: Iterable[str], *, supports_thinking: bool | None = None
22
+ ) -> frozenset[ProviderModelInfo]:
23
+ """Build unknown-capability model metadata from plain provider model ids."""
24
+ return frozenset(
25
+ ProviderModelInfo(model_id=model_id, supports_thinking=supports_thinking)
26
+ for model_id in model_ids
27
+ if model_id.strip()
28
+ )
29
+
30
+
31
+ def extract_openai_model_ids(payload: Any, *, provider_name: str) -> frozenset[str]:
32
+ """Extract model ids from an OpenAI-compatible ``/models`` response."""
33
+ data = _field(payload, "data")
34
+ if not _is_sequence(data):
35
+ raise _malformed(provider_name, "expected top-level data array")
36
+
37
+ model_ids: set[str] = set()
38
+ for item in data:
39
+ model_id = _field(item, "id")
40
+ if not isinstance(model_id, str) or not model_id.strip():
41
+ raise _malformed(provider_name, "expected every data item to include id")
42
+ model_ids.add(model_id)
43
+
44
+ if not model_ids:
45
+ raise _malformed(provider_name, "response did not include any model ids")
46
+ return frozenset(model_ids)
47
+
48
+
49
+ def extract_openrouter_tool_model_ids(
50
+ payload: Any, *, provider_name: str
51
+ ) -> frozenset[str]:
52
+ """Extract OpenRouter model ids that advertise tool-use support."""
53
+ return frozenset(
54
+ info.model_id
55
+ for info in extract_openrouter_tool_model_infos(
56
+ payload, provider_name=provider_name
57
+ )
58
+ )
59
+
60
+
61
+ def extract_openrouter_tool_model_infos(
62
+ payload: Any, *, provider_name: str
63
+ ) -> frozenset[ProviderModelInfo]:
64
+ """Extract OpenRouter tool-capable model ids with thinking capability metadata."""
65
+ data = _field(payload, "data")
66
+ if not _is_sequence(data):
67
+ raise _malformed(provider_name, "expected top-level data array")
68
+
69
+ model_infos: set[ProviderModelInfo] = set()
70
+ for item in data:
71
+ model_id = _field(item, "id")
72
+ if not isinstance(model_id, str) or not model_id.strip():
73
+ raise _malformed(provider_name, "expected every data item to include id")
74
+
75
+ supported_parameters = _field(item, "supported_parameters")
76
+ if not _is_sequence(supported_parameters):
77
+ continue
78
+ supported_parameter_names = {
79
+ param for param in supported_parameters if isinstance(param, str)
80
+ }
81
+ if supported_parameter_names.isdisjoint({"tools", "tool_choice"}):
82
+ continue
83
+ model_infos.add(
84
+ ProviderModelInfo(
85
+ model_id=model_id,
86
+ supports_thinking="reasoning" in supported_parameter_names,
87
+ )
88
+ )
89
+
90
+ return frozenset(model_infos)
91
+
92
+
93
+ def extract_ollama_model_ids(payload: Any, *, provider_name: str) -> frozenset[str]:
94
+ """Extract model ids from Ollama's native ``/api/tags`` response."""
95
+ models = _field(payload, "models")
96
+ if not _is_sequence(models):
97
+ raise _malformed(provider_name, "expected top-level models array")
98
+
99
+ model_ids: set[str] = set()
100
+ for item in models:
101
+ item_ids: list[str] = []
102
+ for key in ("model", "name"):
103
+ value = _field(item, key)
104
+ if isinstance(value, str) and value.strip():
105
+ item_ids.append(value)
106
+ if not item_ids:
107
+ raise _malformed(
108
+ provider_name,
109
+ "expected every models item to include model or name",
110
+ )
111
+ model_ids.update(item_ids)
112
+
113
+ if not model_ids:
114
+ raise _malformed(provider_name, "response did not include any model ids")
115
+ return frozenset(model_ids)
116
+
117
+
118
+ def _field(item: Any, name: str) -> Any:
119
+ if isinstance(item, Mapping):
120
+ return item.get(name)
121
+ return getattr(item, name, None)
122
+
123
+
124
+ def _is_sequence(value: Any) -> bool:
125
+ return isinstance(value, Sequence) and not isinstance(
126
+ value, str | bytes | bytearray
127
+ )
128
+
129
+
130
+ def _malformed(provider_name: str, reason: str) -> ModelListResponseError:
131
+ return ModelListResponseError(
132
+ f"{provider_name} model-list response is malformed: {reason}"
133
+ )