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.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. 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
+ )