codemaster-cli 2.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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- vibe/whats_new.md +5 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import types
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import mistralai
|
|
12
|
+
|
|
13
|
+
from vibe.core.llm.exceptions import BackendErrorBuilder
|
|
14
|
+
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
|
15
|
+
from vibe.core.types import (
|
|
16
|
+
AvailableTool,
|
|
17
|
+
Content,
|
|
18
|
+
FunctionCall,
|
|
19
|
+
LLMChunk,
|
|
20
|
+
LLMMessage,
|
|
21
|
+
LLMUsage,
|
|
22
|
+
Role,
|
|
23
|
+
StrToolChoice,
|
|
24
|
+
ToolCall,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from vibe.core.config import ModelConfig, ProviderConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ParsedContent(NamedTuple):
|
|
32
|
+
content: Content
|
|
33
|
+
reasoning_content: Content | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MistralMapper:
|
|
37
|
+
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
|
|
38
|
+
match msg.role:
|
|
39
|
+
case Role.system:
|
|
40
|
+
return mistralai.SystemMessage(role="system", content=msg.content or "")
|
|
41
|
+
case Role.user:
|
|
42
|
+
return mistralai.UserMessage(role="user", content=msg.content)
|
|
43
|
+
case Role.assistant:
|
|
44
|
+
content: mistralai.AssistantMessageContent
|
|
45
|
+
if msg.reasoning_content:
|
|
46
|
+
content = [
|
|
47
|
+
mistralai.ThinkChunk(
|
|
48
|
+
type="thinking",
|
|
49
|
+
thinking=[
|
|
50
|
+
mistralai.TextChunk(
|
|
51
|
+
type="text", text=msg.reasoning_content
|
|
52
|
+
)
|
|
53
|
+
],
|
|
54
|
+
),
|
|
55
|
+
mistralai.TextChunk(type="text", text=msg.content or ""),
|
|
56
|
+
]
|
|
57
|
+
else:
|
|
58
|
+
content = msg.content or ""
|
|
59
|
+
|
|
60
|
+
return mistralai.AssistantMessage(
|
|
61
|
+
role="assistant",
|
|
62
|
+
content=content,
|
|
63
|
+
tool_calls=[
|
|
64
|
+
mistralai.ToolCall(
|
|
65
|
+
function=mistralai.FunctionCall(
|
|
66
|
+
name=tc.function.name or "",
|
|
67
|
+
arguments=tc.function.arguments or "",
|
|
68
|
+
),
|
|
69
|
+
id=tc.id,
|
|
70
|
+
type=tc.type,
|
|
71
|
+
index=tc.index,
|
|
72
|
+
)
|
|
73
|
+
for tc in msg.tool_calls or []
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
case Role.tool:
|
|
77
|
+
return mistralai.ToolMessage(
|
|
78
|
+
role="tool",
|
|
79
|
+
content=msg.content,
|
|
80
|
+
tool_call_id=msg.tool_call_id,
|
|
81
|
+
name=msg.name,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def prepare_tool(self, tool: AvailableTool) -> mistralai.Tool:
|
|
85
|
+
return mistralai.Tool(
|
|
86
|
+
type="function",
|
|
87
|
+
function=mistralai.Function(
|
|
88
|
+
name=tool.function.name,
|
|
89
|
+
description=tool.function.description,
|
|
90
|
+
parameters=tool.function.parameters,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def prepare_tool_choice(
|
|
95
|
+
self, tool_choice: StrToolChoice | AvailableTool
|
|
96
|
+
) -> mistralai.ChatCompletionStreamRequestToolChoice:
|
|
97
|
+
if isinstance(tool_choice, str):
|
|
98
|
+
return cast(mistralai.ToolChoiceEnum, tool_choice)
|
|
99
|
+
|
|
100
|
+
return mistralai.ToolChoice(
|
|
101
|
+
type="function",
|
|
102
|
+
function=mistralai.FunctionName(name=tool_choice.function.name),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
|
|
106
|
+
thinking_content = getattr(chunk, "thinking", None)
|
|
107
|
+
if not thinking_content:
|
|
108
|
+
return ""
|
|
109
|
+
parts = []
|
|
110
|
+
for inner in thinking_content:
|
|
111
|
+
if hasattr(inner, "type") and inner.type == "text":
|
|
112
|
+
parts.append(getattr(inner, "text", ""))
|
|
113
|
+
elif isinstance(inner, str):
|
|
114
|
+
parts.append(inner)
|
|
115
|
+
return "".join(parts)
|
|
116
|
+
|
|
117
|
+
def parse_content(
|
|
118
|
+
self, content: mistralai.AssistantMessageContent
|
|
119
|
+
) -> ParsedContent:
|
|
120
|
+
if isinstance(content, str):
|
|
121
|
+
return ParsedContent(content=content, reasoning_content=None)
|
|
122
|
+
|
|
123
|
+
concat_content = ""
|
|
124
|
+
concat_reasoning = ""
|
|
125
|
+
for chunk in content:
|
|
126
|
+
if isinstance(chunk, mistralai.FileChunk):
|
|
127
|
+
continue
|
|
128
|
+
if isinstance(chunk, mistralai.TextChunk):
|
|
129
|
+
concat_content += chunk.text
|
|
130
|
+
elif isinstance(chunk, mistralai.ThinkChunk):
|
|
131
|
+
concat_reasoning += self._extract_thinking_text(chunk)
|
|
132
|
+
return ParsedContent(
|
|
133
|
+
content=concat_content,
|
|
134
|
+
reasoning_content=concat_reasoning if concat_reasoning else None,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
|
|
138
|
+
return [
|
|
139
|
+
ToolCall(
|
|
140
|
+
id=tool_call.id,
|
|
141
|
+
function=FunctionCall(
|
|
142
|
+
name=tool_call.function.name,
|
|
143
|
+
arguments=tool_call.function.arguments
|
|
144
|
+
if isinstance(tool_call.function.arguments, str)
|
|
145
|
+
else json.dumps(tool_call.function.arguments, ensure_ascii=False),
|
|
146
|
+
),
|
|
147
|
+
index=tool_call.index,
|
|
148
|
+
)
|
|
149
|
+
for tool_call in tool_calls
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MistralBackend:
|
|
154
|
+
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
|
|
155
|
+
self._client: mistralai.Mistral | None = None
|
|
156
|
+
self._provider = provider
|
|
157
|
+
self._mapper = MistralMapper()
|
|
158
|
+
self._api_key = (
|
|
159
|
+
os.getenv(self._provider.api_key_env_var)
|
|
160
|
+
if self._provider.api_key_env_var
|
|
161
|
+
else None
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
reasoning_field = getattr(provider, "reasoning_field_name", "reasoning_content")
|
|
165
|
+
if reasoning_field != "reasoning_content":
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"Mistral backend does not support custom reasoning_field_name "
|
|
168
|
+
f"(got '{reasoning_field}'). Mistral uses ThinkChunk for reasoning."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Mistral SDK takes server URL without api version as input
|
|
172
|
+
url_pattern = r"(https?://[^/]+)(/v.*)"
|
|
173
|
+
match = re.match(url_pattern, self._provider.api_base)
|
|
174
|
+
if not match:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Invalid API base URL: {self._provider.api_base}. "
|
|
177
|
+
"Expected format: <server_url>/v<api_version>"
|
|
178
|
+
)
|
|
179
|
+
self._server_url = match.group(1)
|
|
180
|
+
self._timeout = timeout
|
|
181
|
+
|
|
182
|
+
async def __aenter__(self) -> MistralBackend:
|
|
183
|
+
self._client = mistralai.Mistral(
|
|
184
|
+
api_key=self._api_key,
|
|
185
|
+
server_url=self._server_url,
|
|
186
|
+
timeout_ms=int(self._timeout * 1000),
|
|
187
|
+
)
|
|
188
|
+
await self._client.__aenter__()
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(
|
|
192
|
+
self,
|
|
193
|
+
exc_type: type[BaseException] | None,
|
|
194
|
+
exc_val: BaseException | None,
|
|
195
|
+
exc_tb: types.TracebackType | None,
|
|
196
|
+
) -> None:
|
|
197
|
+
if self._client is not None:
|
|
198
|
+
await self._client.__aexit__(
|
|
199
|
+
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _get_client(self) -> mistralai.Mistral:
|
|
203
|
+
if self._client is None:
|
|
204
|
+
self._client = mistralai.Mistral(
|
|
205
|
+
api_key=self._api_key, server_url=self._server_url
|
|
206
|
+
)
|
|
207
|
+
return self._client
|
|
208
|
+
|
|
209
|
+
async def complete(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
model: ModelConfig,
|
|
213
|
+
messages: list[LLMMessage],
|
|
214
|
+
temperature: float,
|
|
215
|
+
tools: list[AvailableTool] | None,
|
|
216
|
+
max_tokens: int | None,
|
|
217
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
218
|
+
extra_headers: dict[str, str] | None,
|
|
219
|
+
) -> LLMChunk:
|
|
220
|
+
try:
|
|
221
|
+
merged_messages = merge_consecutive_user_messages(messages)
|
|
222
|
+
response = await self._get_client().chat.complete_async(
|
|
223
|
+
model=model.name,
|
|
224
|
+
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
|
225
|
+
temperature=temperature,
|
|
226
|
+
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
|
227
|
+
if tools
|
|
228
|
+
else None,
|
|
229
|
+
max_tokens=max_tokens,
|
|
230
|
+
tool_choice=self._mapper.prepare_tool_choice(tool_choice)
|
|
231
|
+
if tool_choice
|
|
232
|
+
else None,
|
|
233
|
+
http_headers=extra_headers,
|
|
234
|
+
stream=False,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
parsed = (
|
|
238
|
+
self._mapper.parse_content(response.choices[0].message.content)
|
|
239
|
+
if response.choices[0].message.content
|
|
240
|
+
else ParsedContent(content="", reasoning_content=None)
|
|
241
|
+
)
|
|
242
|
+
return LLMChunk(
|
|
243
|
+
message=LLMMessage(
|
|
244
|
+
role=Role.assistant,
|
|
245
|
+
content=parsed.content,
|
|
246
|
+
reasoning_content=parsed.reasoning_content,
|
|
247
|
+
tool_calls=self._mapper.parse_tool_calls(
|
|
248
|
+
response.choices[0].message.tool_calls
|
|
249
|
+
)
|
|
250
|
+
if response.choices[0].message.tool_calls
|
|
251
|
+
else None,
|
|
252
|
+
),
|
|
253
|
+
usage=LLMUsage(
|
|
254
|
+
prompt_tokens=response.usage.prompt_tokens or 0,
|
|
255
|
+
completion_tokens=response.usage.completion_tokens or 0,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
except mistralai.SDKError as e:
|
|
260
|
+
raise BackendErrorBuilder.build_http_error(
|
|
261
|
+
provider=self._provider.name,
|
|
262
|
+
endpoint=self._server_url,
|
|
263
|
+
response=e.raw_response,
|
|
264
|
+
headers=e.raw_response.headers,
|
|
265
|
+
model=model.name,
|
|
266
|
+
messages=messages,
|
|
267
|
+
temperature=temperature,
|
|
268
|
+
has_tools=bool(tools),
|
|
269
|
+
tool_choice=tool_choice,
|
|
270
|
+
) from e
|
|
271
|
+
except httpx.RequestError as e:
|
|
272
|
+
raise BackendErrorBuilder.build_request_error(
|
|
273
|
+
provider=self._provider.name,
|
|
274
|
+
endpoint=self._server_url,
|
|
275
|
+
error=e,
|
|
276
|
+
model=model.name,
|
|
277
|
+
messages=messages,
|
|
278
|
+
temperature=temperature,
|
|
279
|
+
has_tools=bool(tools),
|
|
280
|
+
tool_choice=tool_choice,
|
|
281
|
+
) from e
|
|
282
|
+
|
|
283
|
+
async def complete_streaming(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
model: ModelConfig,
|
|
287
|
+
messages: list[LLMMessage],
|
|
288
|
+
temperature: float,
|
|
289
|
+
tools: list[AvailableTool] | None,
|
|
290
|
+
max_tokens: int | None,
|
|
291
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
292
|
+
extra_headers: dict[str, str] | None,
|
|
293
|
+
) -> AsyncGenerator[LLMChunk, None]:
|
|
294
|
+
try:
|
|
295
|
+
merged_messages = merge_consecutive_user_messages(messages)
|
|
296
|
+
async for chunk in await self._get_client().chat.stream_async(
|
|
297
|
+
model=model.name,
|
|
298
|
+
messages=[self._mapper.prepare_message(msg) for msg in merged_messages],
|
|
299
|
+
temperature=temperature,
|
|
300
|
+
tools=[self._mapper.prepare_tool(tool) for tool in tools]
|
|
301
|
+
if tools
|
|
302
|
+
else None,
|
|
303
|
+
max_tokens=max_tokens,
|
|
304
|
+
tool_choice=self._mapper.prepare_tool_choice(tool_choice)
|
|
305
|
+
if tool_choice
|
|
306
|
+
else None,
|
|
307
|
+
http_headers=extra_headers,
|
|
308
|
+
):
|
|
309
|
+
parsed = (
|
|
310
|
+
self._mapper.parse_content(chunk.data.choices[0].delta.content)
|
|
311
|
+
if chunk.data.choices[0].delta.content
|
|
312
|
+
else ParsedContent(content="", reasoning_content=None)
|
|
313
|
+
)
|
|
314
|
+
yield LLMChunk(
|
|
315
|
+
message=LLMMessage(
|
|
316
|
+
role=Role.assistant,
|
|
317
|
+
content=parsed.content,
|
|
318
|
+
reasoning_content=parsed.reasoning_content,
|
|
319
|
+
tool_calls=self._mapper.parse_tool_calls(
|
|
320
|
+
chunk.data.choices[0].delta.tool_calls
|
|
321
|
+
)
|
|
322
|
+
if chunk.data.choices[0].delta.tool_calls
|
|
323
|
+
else None,
|
|
324
|
+
),
|
|
325
|
+
usage=LLMUsage(
|
|
326
|
+
prompt_tokens=chunk.data.usage.prompt_tokens or 0
|
|
327
|
+
if chunk.data.usage
|
|
328
|
+
else 0,
|
|
329
|
+
completion_tokens=chunk.data.usage.completion_tokens or 0
|
|
330
|
+
if chunk.data.usage
|
|
331
|
+
else 0,
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
except mistralai.SDKError as e:
|
|
336
|
+
raise BackendErrorBuilder.build_http_error(
|
|
337
|
+
provider=self._provider.name,
|
|
338
|
+
endpoint=self._server_url,
|
|
339
|
+
response=e.raw_response,
|
|
340
|
+
headers=e.raw_response.headers,
|
|
341
|
+
model=model.name,
|
|
342
|
+
messages=messages,
|
|
343
|
+
temperature=temperature,
|
|
344
|
+
has_tools=bool(tools),
|
|
345
|
+
tool_choice=tool_choice,
|
|
346
|
+
) from e
|
|
347
|
+
except httpx.RequestError as e:
|
|
348
|
+
raise BackendErrorBuilder.build_request_error(
|
|
349
|
+
provider=self._provider.name,
|
|
350
|
+
endpoint=self._server_url,
|
|
351
|
+
error=e,
|
|
352
|
+
model=model.name,
|
|
353
|
+
messages=messages,
|
|
354
|
+
temperature=temperature,
|
|
355
|
+
has_tools=bool(tools),
|
|
356
|
+
tool_choice=tool_choice,
|
|
357
|
+
) from e
|
|
358
|
+
|
|
359
|
+
async def count_tokens(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
model: ModelConfig,
|
|
363
|
+
messages: list[LLMMessage],
|
|
364
|
+
temperature: float = 0.0,
|
|
365
|
+
tools: list[AvailableTool] | None = None,
|
|
366
|
+
tool_choice: StrToolChoice | AvailableTool | None = None,
|
|
367
|
+
extra_headers: dict[str, str] | None = None,
|
|
368
|
+
) -> int:
|
|
369
|
+
result = await self.complete(
|
|
370
|
+
model=model,
|
|
371
|
+
messages=messages,
|
|
372
|
+
temperature=temperature,
|
|
373
|
+
tools=tools,
|
|
374
|
+
max_tokens=1,
|
|
375
|
+
tool_choice=tool_choice,
|
|
376
|
+
extra_headers=extra_headers,
|
|
377
|
+
)
|
|
378
|
+
if result.usage is None:
|
|
379
|
+
raise ValueError("Missing usage in non streaming completion")
|
|
380
|
+
|
|
381
|
+
return result.usage.prompt_tokens
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, ClassVar
|
|
5
|
+
|
|
6
|
+
import google.auth
|
|
7
|
+
from google.auth.transport.requests import Request
|
|
8
|
+
|
|
9
|
+
from vibe.core.config import ProviderConfig
|
|
10
|
+
from vibe.core.llm.backend.anthropic import AnthropicAdapter
|
|
11
|
+
from vibe.core.llm.backend.base import PreparedRequest
|
|
12
|
+
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_vertex_access_token() -> str:
|
|
16
|
+
|
|
17
|
+
credentials, _ = google.auth.default(
|
|
18
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
|
19
|
+
)
|
|
20
|
+
credentials.refresh(Request())
|
|
21
|
+
return credentials.token
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_vertex_base_url(region: str) -> str:
|
|
25
|
+
if region == "global":
|
|
26
|
+
return "https://aiplatform.googleapis.com"
|
|
27
|
+
return f"https://{region}-aiplatform.googleapis.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_vertex_endpoint(
|
|
31
|
+
region: str, project_id: str, model: str, streaming: bool = False
|
|
32
|
+
) -> str:
|
|
33
|
+
action = "streamRawPredict" if streaming else "rawPredict"
|
|
34
|
+
return (
|
|
35
|
+
f"/v1/projects/{project_id}/locations/{region}/"
|
|
36
|
+
f"publishers/anthropic/models/{model}:{action}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VertexAnthropicAdapter(AnthropicAdapter):
|
|
41
|
+
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
|
|
42
|
+
|
|
43
|
+
endpoint: ClassVar[str] = ""
|
|
44
|
+
# Vertex AI doesn't support beta features
|
|
45
|
+
BETA_FEATURES: ClassVar[str] = ""
|
|
46
|
+
|
|
47
|
+
def prepare_request( # noqa: PLR0913
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
model_name: str,
|
|
51
|
+
messages: list[LLMMessage],
|
|
52
|
+
temperature: float,
|
|
53
|
+
tools: list[AvailableTool] | None,
|
|
54
|
+
max_tokens: int | None,
|
|
55
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
56
|
+
enable_streaming: bool,
|
|
57
|
+
provider: ProviderConfig,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
thinking: str = "off",
|
|
60
|
+
) -> PreparedRequest:
|
|
61
|
+
project_id = provider.project_id
|
|
62
|
+
region = provider.region
|
|
63
|
+
|
|
64
|
+
if not project_id:
|
|
65
|
+
raise ValueError("project_id is required in provider config for Vertex AI")
|
|
66
|
+
if not region:
|
|
67
|
+
raise ValueError("region is required in provider config for Vertex AI")
|
|
68
|
+
|
|
69
|
+
system_prompt, converted_messages = self._mapper.prepare_messages(messages)
|
|
70
|
+
converted_tools = self._mapper.prepare_tools(tools)
|
|
71
|
+
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
|
72
|
+
|
|
73
|
+
# Build vertex-specific payload (no "model" key, uses anthropic_version)
|
|
74
|
+
payload: dict[str, Any] = {
|
|
75
|
+
"anthropic_version": "vertex-2023-10-16",
|
|
76
|
+
"messages": converted_messages,
|
|
77
|
+
}
|
|
78
|
+
self._apply_thinking_config(
|
|
79
|
+
payload,
|
|
80
|
+
model_name=model_name,
|
|
81
|
+
messages=converted_messages,
|
|
82
|
+
temperature=temperature,
|
|
83
|
+
max_tokens=max_tokens,
|
|
84
|
+
thinking=thinking,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if system_blocks := self._build_system_blocks(system_prompt):
|
|
88
|
+
payload["system"] = system_blocks
|
|
89
|
+
|
|
90
|
+
if converted_tools:
|
|
91
|
+
payload["tools"] = converted_tools
|
|
92
|
+
|
|
93
|
+
if converted_tool_choice:
|
|
94
|
+
payload["tool_choice"] = converted_tool_choice
|
|
95
|
+
|
|
96
|
+
if enable_streaming:
|
|
97
|
+
payload["stream"] = True
|
|
98
|
+
|
|
99
|
+
self._add_cache_control_to_last_user_message(converted_messages)
|
|
100
|
+
|
|
101
|
+
access_token = get_vertex_access_token()
|
|
102
|
+
|
|
103
|
+
headers = {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
"Authorization": f"Bearer {access_token}",
|
|
106
|
+
"anthropic-beta": self.BETA_FEATURES,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
endpoint = build_vertex_endpoint(
|
|
110
|
+
region, project_id, model_name, streaming=enable_streaming
|
|
111
|
+
)
|
|
112
|
+
base_url = build_vertex_base_url(region)
|
|
113
|
+
|
|
114
|
+
body = json.dumps(payload).encode("utf-8")
|
|
115
|
+
return PreparedRequest(endpoint, headers, body, base_url=base_url)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
10
|
+
|
|
11
|
+
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorDetail(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="ignore")
|
|
16
|
+
message: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PayloadSummary(BaseModel):
|
|
20
|
+
model: str
|
|
21
|
+
message_count: int
|
|
22
|
+
approx_chars: int
|
|
23
|
+
temperature: float
|
|
24
|
+
has_tools: bool
|
|
25
|
+
tool_choice: StrToolChoice | AvailableTool | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BackendError(RuntimeError):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
provider: str,
|
|
33
|
+
endpoint: str,
|
|
34
|
+
status: int | None,
|
|
35
|
+
reason: str | None,
|
|
36
|
+
headers: Mapping[str, str] | None,
|
|
37
|
+
body_text: str | None,
|
|
38
|
+
parsed_error: str | None,
|
|
39
|
+
model: str,
|
|
40
|
+
payload_summary: PayloadSummary,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.provider = provider
|
|
43
|
+
self.endpoint = endpoint
|
|
44
|
+
self.status = status
|
|
45
|
+
self.reason = reason
|
|
46
|
+
self.headers = {k.lower(): v for k, v in (headers or {}).items()}
|
|
47
|
+
self.body_text = body_text or ""
|
|
48
|
+
self.parsed_error = parsed_error
|
|
49
|
+
self.model = model
|
|
50
|
+
self.payload_summary = payload_summary
|
|
51
|
+
super().__init__(self._fmt())
|
|
52
|
+
|
|
53
|
+
def _fmt(self) -> str:
|
|
54
|
+
if self.status == HTTPStatus.UNAUTHORIZED:
|
|
55
|
+
return "Invalid API key. Please check your API key and try again."
|
|
56
|
+
|
|
57
|
+
if self.status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
58
|
+
return "Rate limit exceeded. Please wait a moment before trying again."
|
|
59
|
+
|
|
60
|
+
rid = self.headers.get("x-request-id") or self.headers.get("request-id")
|
|
61
|
+
status_label = (
|
|
62
|
+
f"{self.status} {HTTPStatus(self.status).phrase}" if self.status else "N/A"
|
|
63
|
+
)
|
|
64
|
+
parts = [
|
|
65
|
+
f"LLM backend error [{self.provider}]",
|
|
66
|
+
f" status: {status_label}",
|
|
67
|
+
f" reason: {self.reason or 'N/A'}",
|
|
68
|
+
f" request_id: {rid or 'N/A'}",
|
|
69
|
+
f" endpoint: {self.endpoint}",
|
|
70
|
+
f" model: {self.model}",
|
|
71
|
+
f" provider_message: {self.parsed_error or 'N/A'}",
|
|
72
|
+
f" body_excerpt: {self._excerpt(self.body_text)}",
|
|
73
|
+
f" payload_summary: {self.payload_summary.model_dump_json(exclude_none=True)}",
|
|
74
|
+
]
|
|
75
|
+
return "\n".join(parts)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _excerpt(s: str, *, n: int = 400) -> str:
|
|
79
|
+
s = s.strip().replace("\n", " ")
|
|
80
|
+
return s[:n] + ("…" if len(s) > n else "")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ErrorResponse(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="ignore")
|
|
85
|
+
|
|
86
|
+
error: ErrorDetail | dict[str, Any] | None = None
|
|
87
|
+
message: str | None = None
|
|
88
|
+
detail: str | None = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def primary_message(self) -> str | None:
|
|
92
|
+
if e := self.error:
|
|
93
|
+
match e:
|
|
94
|
+
case {"message": str(m)}:
|
|
95
|
+
return m
|
|
96
|
+
case {"type": str(t)}:
|
|
97
|
+
return f"Error: {t}"
|
|
98
|
+
case ErrorDetail(message=str(m)):
|
|
99
|
+
return m
|
|
100
|
+
if m := self.message:
|
|
101
|
+
return m
|
|
102
|
+
if d := self.detail:
|
|
103
|
+
return d
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BackendErrorBuilder:
|
|
108
|
+
@classmethod
|
|
109
|
+
def build_http_error(
|
|
110
|
+
cls,
|
|
111
|
+
*,
|
|
112
|
+
provider: str,
|
|
113
|
+
endpoint: str,
|
|
114
|
+
response: httpx.Response,
|
|
115
|
+
headers: Mapping[str, str] | None,
|
|
116
|
+
model: str,
|
|
117
|
+
messages: list[LLMMessage],
|
|
118
|
+
temperature: float,
|
|
119
|
+
has_tools: bool,
|
|
120
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
121
|
+
) -> BackendError:
|
|
122
|
+
try:
|
|
123
|
+
body_text = response.text
|
|
124
|
+
except Exception: # On streaming responses, we can't read the body
|
|
125
|
+
body_text = None
|
|
126
|
+
|
|
127
|
+
return BackendError(
|
|
128
|
+
provider=provider,
|
|
129
|
+
endpoint=endpoint,
|
|
130
|
+
status=response.status_code,
|
|
131
|
+
reason=response.reason_phrase,
|
|
132
|
+
headers=headers or {},
|
|
133
|
+
body_text=body_text,
|
|
134
|
+
parsed_error=cls._parse_provider_error(body_text),
|
|
135
|
+
model=model,
|
|
136
|
+
payload_summary=cls._payload_summary(
|
|
137
|
+
model, messages, temperature, has_tools, tool_choice
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def build_request_error(
|
|
143
|
+
cls,
|
|
144
|
+
*,
|
|
145
|
+
provider: str,
|
|
146
|
+
endpoint: str,
|
|
147
|
+
error: httpx.RequestError,
|
|
148
|
+
model: str,
|
|
149
|
+
messages: list[LLMMessage],
|
|
150
|
+
temperature: float,
|
|
151
|
+
has_tools: bool,
|
|
152
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
153
|
+
) -> BackendError:
|
|
154
|
+
return BackendError(
|
|
155
|
+
provider=provider,
|
|
156
|
+
endpoint=endpoint,
|
|
157
|
+
status=None,
|
|
158
|
+
reason=str(error) or repr(error),
|
|
159
|
+
headers={},
|
|
160
|
+
body_text=None,
|
|
161
|
+
parsed_error="Network error",
|
|
162
|
+
model=model,
|
|
163
|
+
payload_summary=cls._payload_summary(
|
|
164
|
+
model, messages, temperature, has_tools, tool_choice
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _parse_provider_error(body_text: str | None) -> str | None:
|
|
170
|
+
if not body_text:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(body_text)
|
|
174
|
+
error_model = ErrorResponse.model_validate(data)
|
|
175
|
+
return error_model.primary_message
|
|
176
|
+
except (json.JSONDecodeError, ValidationError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _payload_summary(
|
|
181
|
+
model_name: str,
|
|
182
|
+
messages: list[LLMMessage],
|
|
183
|
+
temperature: float,
|
|
184
|
+
has_tools: bool,
|
|
185
|
+
tool_choice: StrToolChoice | AvailableTool | None,
|
|
186
|
+
) -> PayloadSummary:
|
|
187
|
+
total_chars = sum(len(m.content or "") for m in messages)
|
|
188
|
+
return PayloadSummary(
|
|
189
|
+
model=model_name,
|
|
190
|
+
message_count=len(messages),
|
|
191
|
+
approx_chars=total_chars,
|
|
192
|
+
temperature=temperature,
|
|
193
|
+
has_tools=has_tools,
|
|
194
|
+
tool_choice=tool_choice,
|
|
195
|
+
)
|