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,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
|
providers/groq/client.py
ADDED
|
@@ -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
|
providers/kimi/client.py
ADDED
|
@@ -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,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,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,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
|
+
)
|