Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Peter Evers <peter.evers@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-05-29 16:17:54 +02:00 committed by GitHub
parent 198277af3f
commit ad0d5c9520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 969 additions and 461 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.12.1"
__version__ = "2.13.0"

View file

@ -1516,6 +1516,14 @@ class VibeAcpAgentLoop(AcpAgent):
)
return provider, auth_state
def _process_env_value_before_dotenv_load(
self, provider: ProviderConfig
) -> str | None:
if not provider.api_key_env_var:
return None
return self._environ_before_dotenv_load.get(provider.api_key_env_var)
def _handle_auth_status(self) -> dict[str, Any]:
_, auth_state = self._assess_current_auth_state()
return _auth_status_response_from_auth_state(auth_state).model_dump(
@ -1531,6 +1539,14 @@ class VibeAcpAgentLoop(AcpAgent):
try:
self._remove_api_key(provider)
if (
auth_state.kind
== AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV
and auth_state.env_key
):
process_env_value = self._process_env_value_before_dotenv_load(provider)
if process_env_value:
os.environ[auth_state.env_key] = process_env_value
except (OSError, ValueError) as exc:
raise InternalError(f"Failed to sign out: {exc}") from exc
@ -1650,11 +1666,8 @@ class VibeAcpAgentLoop(AcpAgent):
)
await session.agent_loop.compact(extra_instructions=cmd_args.strip())
new_tokens = session.agent_loop.stats.context_tokens
end_event = CompactEndEvent(
old_context_tokens=old_tokens or 0,
new_context_tokens=new_tokens or 0,
summary_length=0,
old_session_id=old_session_id,
new_session_id=session.agent_loop.session_id,

View file

@ -26,7 +26,7 @@ from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
from vibe.core.utils import compact_reduction_display
from vibe.core.utils import compact_complete_display
if TYPE_CHECKING:
from vibe.core.config import ModelConfig
@ -230,9 +230,7 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
content=TextContentBlock(
type="text",
text=(
compact_reduction_display(
event.old_context_tokens,
event.new_context_tokens,
compact_complete_display(
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
)

View file

@ -12,8 +12,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIResponse,
)
from vibe.core.config import (
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_VIBE_BASE_URL,
ProviderConfig,
)
from vibe.core.types import Backend
@ -97,20 +97,18 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
def plan_offer_cta(
payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
) -> str | None:
if not payload:
return
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
switch_to_pro_key_url = console_cli_url
upgrade_url = console_cli_url
vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})"
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})"
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911

View file

@ -2183,32 +2183,26 @@ class VibeApp(App): # noqa: PLR0904
if not self.event_handler:
return
old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
self._run_compact(compact_msg, old_session_id, cmd_args.strip())
)
async def _run_compact(
self,
compact_msg: CompactMessage,
old_tokens: int,
old_session_id: str,
extra_instructions: str = "",
) -> None:
self._agent_running = True
try:
await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(
old_tokens=old_tokens,
new_tokens=new_tokens,
old_session_id=old_session_id,
new_session_id=self.agent_loop.session_id,
old_session_id=old_session_id, new_session_id=self.agent_loop.session_id
)
except asyncio.CancelledError:
@ -2887,13 +2881,18 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.switching_mode = True
loop = asyncio.get_running_loop()
def schedule_switch() -> None:
self._switch_agent_generation += 1
my_gen = self._switch_agent_generation
def switch_agent_sync() -> None:
try:
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
future = asyncio.run_coroutine_threadsafe(
self.agent_loop.switch_agent(new_profile.name), loop
)
future.result()
self.agent_loop.set_approval_callback(self._approval_callback)
self.agent_loop.set_user_input_callback(self._user_input_callback)
finally:
@ -3031,7 +3030,7 @@ class VibeApp(App): # noqa: PLR0904
if content is not None:
body = content
plan_offer = plan_offer_cta(
self._plan_info, console_base_url=self.config.console_base_url
self._plan_info, vibe_base_url=self.config.vibe_base_url
)
if plan_offer is not None:
body = f"{body}\n\n{plan_offer}"
@ -3059,12 +3058,10 @@ class VibeApp(App): # noqa: PLR0904
if not self._show_vscode_extension_promo:
return
promo_message = VscodeExtensionPromoMessage()
if self._history_widget_indices:
promo_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_anchor = chat.is_at_bottom
await chat.mount(promo_message, after=messages_area)
await chat.mount(promo_message, before=messages_area)
if should_anchor:
chat.anchor()
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)

View file

@ -1281,10 +1281,6 @@ NarratorStatus {
link-color-hover: $mistral_orange;
}
.vscode-extension-promo-message.after-history {
margin-top: 1;
}
#sessionpicker-app {
width: 100%;
height: auto;

View file

@ -229,10 +229,7 @@ class EventHandler:
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact:
self.current_compact.set_complete(
old_tokens=event.old_context_tokens,
new_tokens=event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
old_session_id=event.old_session_id, new_session_id=event.new_session_id
)
self.current_compact = None

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from textual.message import Message
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.core.utils import compact_reduction_display
from vibe.core.utils import compact_complete_display
class CompactMessage(StatusMessage):
@ -15,8 +15,6 @@ class CompactMessage(StatusMessage):
def __init__(self) -> None:
super().__init__()
self.add_class("compact-message")
self.old_tokens: int | None = None
self.new_tokens: int | None = None
self.old_session_id: str | None = None
self.new_session_id: str | None = None
self.error_message: str | None = None
@ -28,23 +26,13 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
return compact_reduction_display(
self.old_tokens,
self.new_tokens,
old_session_id=self.old_session_id,
new_session_id=self.new_session_id,
return compact_complete_display(
old_session_id=self.old_session_id, new_session_id=self.new_session_id
)
def set_complete(
self,
old_tokens: int | None = None,
new_tokens: int | None = None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
self, *, old_session_id: str | None = None, new_session_id: str | None = None
) -> None:
self.old_tokens = old_tokens
self.new_tokens = new_tokens
self.old_session_id = old_session_id
self.new_session_id = new_session_id
self.stop_spinning(success=True)

View file

@ -810,7 +810,6 @@ class AgentLoop: # noqa: PLR0904
)
compact_status: Literal["success", "failure", "cancelled"] = "success"
new_tokens = self.stats.context_tokens
try:
summary = await self.compact()
except asyncio.CancelledError:
@ -820,10 +819,8 @@ class AgentLoop: # noqa: PLR0904
compact_status = "failure"
raise
finally:
new_tokens = self.stats.context_tokens
self.telemetry_client.send_auto_compact_triggered(
nb_context_tokens_before=old_tokens,
nb_context_tokens_after=new_tokens,
auto_compact_threshold=threshold,
status=compact_status,
session_id=old_session_id,
@ -832,8 +829,6 @@ class AgentLoop: # noqa: PLR0904
yield CompactEndEvent(
tool_call_id=tool_call_id,
old_context_tokens=old_tokens,
new_context_tokens=new_tokens,
summary_length=len(summary),
old_session_id=old_session_id,
new_session_id=self.session_id,
@ -1724,21 +1719,16 @@ class AgentLoop: # noqa: PLR0904
system_message = self.messages[0]
wrapped_summary = f"{summary_prefix}\n{summary_content}"
summary_message = LLMMessage(role=Role.user, content=wrapped_summary)
summary_message = LLMMessage(
role=Role.user, content=wrapped_summary, injected=True
)
self.messages.reset([system_message, *prior_user_messages, summary_message])
active_model = self.config.get_active_model()
await self._reset_session()
actual_context_tokens = await self.backend.count_tokens(
model=active_model,
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers=self._get_extra_headers(),
metadata=self._build_backend_metadata().model_dump(exclude_none=True),
)
self.stats.context_tokens = actual_context_tokens
# Context size is unknown without an API call; reset to 0. The next
# LLM turn recomputes it accurately from real usage (_update_stats).
self.stats.context_tokens = 0
await self.session_logger.save_interaction(
self.messages,
self.stats,

View file

@ -34,11 +34,13 @@ def collect_prior_user_messages(
content = m.content or ""
cost = approx_token_count(content)
if cost <= remaining:
selected.append(LLMMessage(role=Role.user, content=content))
selected.append(LLMMessage(role=Role.user, content=content, injected=True))
remaining -= cost
else:
truncated = truncate_middle_to_tokens(content, remaining)
selected.append(LLMMessage(role=Role.user, content=truncated))
selected.append(
LLMMessage(role=Role.user, content=truncated, injected=True)
)
remaining = 0
selected.reverse()

View file

@ -10,6 +10,7 @@ from vibe.core.config._settings import (
DEFAULT_TRANSCRIBE_PROVIDERS,
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
THINKING_LEVELS,
ConnectorConfig,
ExperimentsConfig,
@ -76,6 +77,7 @@ __all__ = [
"DEFAULT_TRANSCRIBE_PROVIDERS",
"DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS",
"DEFAULT_VIBE_BASE_URL",
"THINKING_LEVELS",
"AppendToList",
"ConfigDefinitionError",

View file

@ -32,7 +32,7 @@ from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.types import Backend
from vibe.core.utils import get_server_url_from_api_base
from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
def _strip_bash_pattern_wildcard(pattern: str) -> str:
@ -165,6 +165,7 @@ DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai"
class ProviderConfig(BaseModel):
@ -520,6 +521,7 @@ class VibeConfig(BaseSettings):
enable_update_checks: bool = True
enable_auto_update: bool = True
enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = 720.0
auto_compact_threshold: int = 200_000
@ -537,6 +539,7 @@ class VibeConfig(BaseSettings):
otel_endpoint: str = Field(default="", exclude=True)
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
vibe_base_url: str = Field(default=DEFAULT_VIBE_BASE_URL, exclude=True)
enable_experimental_hooks: bool = Field(default=False, exclude=True)
@ -1064,7 +1067,11 @@ class VibeConfig(BaseSettings):
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()
return cls(**(overrides or {}))
config = cls(**(overrides or {}))
configure_ssl_context(
enable_system_trust_store=config.enable_system_trust_store
)
return config
@classmethod
def create_default(cls) -> dict[str, Any]:

View file

@ -324,12 +324,6 @@ class AnthropicAdapter(APIAdapter):
"prompt-caching-2024-07-31,"
"context-1m-2025-08-07"
)
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
"low": 1024,
"medium": 10_000,
"high": 32_000,
"max": 128_000,
}
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192
@ -375,66 +369,32 @@ class AnthropicAdapter(APIAdapter):
if last_block.get("type") in {"text", "image", "tool_result"}:
last_block["cache_control"] = {"type": "ephemeral"}
# Anthropic models that require the `thinking={"type":"adaptive"}` +
# `output_config.effort` shape and reject the older
# `thinking={"type":"enabled","budget_tokens":...}` shape. Add new
# adaptive-only model families here as Anthropic ships them.
ADAPTIVE_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({"opus-4-6", "opus-4-7"})
# Anthropic models that have deprecated the `temperature` parameter and
# reject any payload containing it. Add new families here as Anthropic
# ships them.
TEMPERATURE_DEPRECATED_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({
"opus-4-7"
})
@classmethod
def _is_adaptive_model(cls, model_name: str) -> bool:
return any(tag in model_name for tag in cls.ADAPTIVE_MODEL_TAGS)
@classmethod
def _is_temperature_deprecated_model(cls, model_name: str) -> bool:
return any(tag in model_name for tag in cls.TEMPERATURE_DEPRECATED_MODEL_TAGS)
def _apply_thinking_config(
self,
payload: dict[str, Any],
*,
model_name: str,
messages: list[dict[str, Any]],
temperature: float,
max_tokens: int | None,
thinking: str,
) -> None:
has_thinking = self._has_thinking_content(messages)
thinking_level = thinking
temperature_deprecated = self._is_temperature_deprecated_model(model_name)
if thinking_level == "off" and not has_thinking:
if not temperature_deprecated:
payload["temperature"] = temperature
if max_tokens is not None:
payload["max_tokens"] = max_tokens
else:
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
payload["max_tokens"] = (
max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS
)
return
# Resolve effective level: use config, or fallback to "medium" when
# forced by thinking content in history
effective_level = thinking_level if thinking_level != "off" else "medium"
if self._is_adaptive_model(model_name):
payload["thinking"] = {"type": "adaptive", "display": "summarized"}
payload["output_config"] = {"effort": effective_level}
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
else:
budget = self.THINKING_BUDGETS[effective_level]
payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
default_max = budget + self.DEFAULT_MAX_TOKENS
if not temperature_deprecated:
payload["temperature"] = 1
payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
payload["thinking"] = {"type": "adaptive", "display": "summarized"}
payload["output_config"] = {"effort": effective_level}
payload["max_tokens"] = (
max_tokens if max_tokens is not None else self.DEFAULT_ADAPTIVE_MAX_TOKENS
)
def _build_payload(
self,
@ -442,7 +402,6 @@ class AnthropicAdapter(APIAdapter):
model_name: str,
system_prompt: str | None,
messages: list[dict[str, Any]],
temperature: float,
tools: list[dict[str, Any]] | None,
max_tokens: int | None,
tool_choice: dict[str, Any] | None,
@ -452,12 +411,7 @@ class AnthropicAdapter(APIAdapter):
payload: dict[str, Any] = {"model": model_name, "messages": messages}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
payload, messages=messages, max_tokens=max_tokens, thinking=thinking
)
if system_blocks := self._build_system_blocks(system_prompt):
@ -498,7 +452,6 @@ class AnthropicAdapter(APIAdapter):
model_name=model_name,
system_prompt=system_prompt,
messages=converted_messages,
temperature=temperature,
tools=converted_tools,
max_tokens=max_tokens,
tool_choice=converted_tool_choice,

View file

@ -416,35 +416,6 @@ class GenericBackend:
return
yield json.loads(value.strip())
async def count_tokens(
self,
*,
model: ModelConfig,
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int:
probe_messages = list(messages)
if not probe_messages or probe_messages[-1].role != Role.user:
probe_messages.append(LLMMessage(role=Role.user, content=""))
result = await self.complete(
model=model,
messages=probe_messages,
temperature=temperature,
tools=tools,
max_tokens=16, # Minimal amount for openrouter with openai models
tool_choice=tool_choice,
extra_headers=extra_headers,
)
if result.usage is None:
raise ValueError("Missing usage in non streaming completion")
return result.usage.prompt_tokens
async def close(self) -> None:
if self._owns_client and self._client:
await self._client.aclose()

View file

@ -426,29 +426,3 @@ class MistralBackend:
has_tools=bool(tools),
tool_choice=tool_choice,
) from e
async def count_tokens(
self,
*,
model: ModelConfig,
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None = None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None = None,
metadata: dict[str, str] | None = None,
) -> int:
result = await self.complete(
model=model,
messages=messages,
temperature=temperature,
tools=tools,
max_tokens=1,
tool_choice=tool_choice,
extra_headers=extra_headers,
metadata=metadata,
)
if result.usage is None:
raise ValueError("Missing usage in non streaming completion")
return result.usage.prompt_tokens

View file

@ -96,9 +96,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
}
self._apply_thinking_config(
payload,
model_name=model_name,
messages=converted_messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)

View file

@ -14,7 +14,7 @@ class BackendLike(Protocol):
"""Port protocol for dependency-injectable LLM backends.
Any backend used by AgentLoop should implement this async context manager
interface with `complete`, `complete_streaming` and `count_tokens` methods.
interface with `complete` and `complete_streaming` methods.
"""
async def __aenter__(self) -> BackendLike: ...
@ -92,35 +92,3 @@ class BackendLike(Protocol):
BackendError: If the API request fails
"""
...
async def count_tokens(
self,
*,
model: ModelConfig,
messages: Sequence[LLMMessage],
temperature: float = 0.0,
tools: list[AvailableTool] | None,
tool_choice: StrToolChoice | AvailableTool | None = None,
extra_headers: dict[str, str] | None,
metadata: dict[str, str] | None = None,
) -> int:
"""Count the number of tokens in the prompt without generating a real response.
This is useful for:
- Determining system prompt token count
- Checking context size after compaction
- Pre-flight token validation
Args:
model: Model configuration
messages: List of messages to count tokens for
temperature: Sampling temperature
tools: Optional list of available tools
tool_choice: How to choose tools
extra_headers: Additional HTTP headers to include
metadata: Optional metadata to attach to the request
Returns:
The number of prompt tokens
"""
...

View file

@ -79,6 +79,7 @@ enable_telemetry = true
enable_update_checks = true
enable_auto_update = true
enable_notifications = true
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
api_timeout = 720.0 # API request timeout in seconds
auto_compact_threshold = 200000 # Token count before auto-compaction
@ -267,6 +268,13 @@ browser_auth_base_url = "https://console.mistral.ai"
browser_auth_api_base_url = "https://console.mistral.ai/api"
```
Self-hosted deployments can point Vibe CLI upgrade and API-key links to their
Le Chat web deployment, where the Vibe API key is managed:
```toml
vibe_base_url = "https://chat.mistral.ai"
```
### Hooks (Experimental)
Hooks let users run shell commands automatically at specific points during a

View file

@ -245,7 +245,6 @@ class TelemetryClient:
self,
*,
nb_context_tokens_before: int,
nb_context_tokens_after: int,
auto_compact_threshold: int,
status: Literal["success", "failure", "cancelled"],
session_id: str | None = None,
@ -253,7 +252,6 @@ class TelemetryClient:
) -> None:
payload = {
"nb_context_tokens_before": nb_context_tokens_before,
"nb_context_tokens_after": nb_context_tokens_after,
"auto_compact_threshold": auto_compact_threshold,
"status": status,
}

View file

@ -11,6 +11,7 @@ from vibe.core.tools.mcp.tools import (
call_tool_stdio,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
create_vibe_mcp_http_client,
list_tools_http,
list_tools_stdio,
)
@ -26,6 +27,7 @@ __all__ = [
"call_tool_stdio",
"create_mcp_http_proxy_tool_class",
"create_mcp_stdio_proxy_tool_class",
"create_vibe_mcp_http_client",
"list_tools_http",
"list_tools_stdio",
]

View file

@ -9,11 +9,12 @@ from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.streamable_http import streamable_http_client
from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
@ -25,11 +26,18 @@ from vibe.core.tools.base import (
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
# Mirrors MCP's default Streamable HTTP timeout values while avoiding an import from
# mcp.shared._httpx_utils, which is an internal module.
_MCP_DEFAULT_TIMEOUT = 30.0
_MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0
def _stderr_logger_thread(read_fd: int) -> None:
with open(read_fd, "rb") as f:
for line in iter(f.readline, b""):
@ -167,6 +175,15 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult
return MCPToolResult(server=server, tool=tool, text=text, structured=None)
def create_vibe_mcp_http_client(headers: dict[str, str] | None) -> httpx.AsyncClient:
return httpx.AsyncClient(
follow_redirects=True,
headers=headers,
timeout=httpx.Timeout(_MCP_DEFAULT_TIMEOUT, read=_MCP_DEFAULT_SSE_READ_TIMEOUT),
verify=build_ssl_context(),
)
async def list_tools_http(
url: str,
*,
@ -174,11 +191,18 @@ async def list_tools_http(
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async with create_vibe_mcp_http_client(headers) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
write,
_,
):
async with ClientSession(
read, write, read_timeout_seconds=timeout
) as session:
await session.initialize()
tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_http(
@ -195,18 +219,23 @@ async def call_tool_http(
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(
async with create_vibe_mcp_http_client(headers) as http_client:
async with streamable_http_client(url, http_client=http_client) as (
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result(url, tool_name, result)
_,
):
async with ClientSession(
read,
write,
read_timeout_seconds=init_timeout,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
result = await session.call_tool(
tool_name, arguments, read_timeout_seconds=call_timeout
)
return _parse_call_result(url, tool_name, result)
def create_mcp_http_proxy_tool_class(

View file

@ -430,8 +430,6 @@ class CompactStartEvent(BaseEvent):
class CompactEndEvent(BaseEvent):
old_context_tokens: int
new_context_tokens: int
summary_length: int
old_session_id: str | None = None
new_session_id: str | None = None

View file

@ -12,9 +12,10 @@ from vibe.core.utils.concurrency import (
ConversationLimitException,
run_sync,
)
from vibe.core.utils.display import compact_reduction_display
from vibe.core.utils.display import compact_complete_display
from vibe.core.utils.http import (
build_ssl_context,
configure_ssl_context,
get_server_url_from_api_base,
get_user_agent,
)
@ -55,7 +56,8 @@ __all__ = [
"async_generator_retry",
"async_retry",
"build_ssl_context",
"compact_reduction_display",
"compact_complete_display",
"configure_ssl_context",
"get_platform_display_name",
"get_platform_id",
"get_server_url_from_api_base",

View file

@ -3,23 +3,11 @@ from __future__ import annotations
from vibe.core.session.session_id import shorten_session_id
def compact_reduction_display(
old_tokens: int | None,
new_tokens: int | None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
def compact_complete_display(
old_session_id: str | None = None, new_session_id: str | None = None
) -> str:
message = "Compaction complete"
if old_tokens is not None and new_tokens is not None:
reduction = old_tokens - new_tokens
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
message = (
f"{message}: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)
message = "Compaction completed."
if old_session_id is not None and new_session_id is not None:
short_old = shorten_session_id(old_session_id)
short_new = shorten_session_id(new_session_id)

View file

@ -6,14 +6,28 @@ import re
import ssl
import certifi
import truststore
from vibe import __version__
from vibe.core.types import Backend
_use_system_trust_store = False
def configure_ssl_context(*, enable_system_trust_store: bool) -> None:
global _use_system_trust_store
if _use_system_trust_store == enable_system_trust_store:
return
_use_system_trust_store = enable_system_trust_store
build_ssl_context.cache_clear()
@functools.lru_cache(maxsize=1)
def build_ssl_context() -> ssl.SSLContext:
ctx = ssl.create_default_context(cafile=certifi.where())
if _use_system_trust_store:
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
else:
ctx = ssl.create_default_context(cafile=certifi.where())
# Custom certs are additive so private-CA users don't lose public roots.
ssl_cert_file = os.getenv("SSL_CERT_FILE")

View file

@ -128,6 +128,7 @@ def assess_auth_state(
return _auth_state(
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True,
sign_out_available=True,
env_key=env_key,
)

View file

@ -42,6 +42,7 @@ class OnboardingApp(App[str | None]):
self._config = config
self._provider = config.provider
self._vibe_base_url = config.vibe_base_url
self._entrypoint_metadata = entrypoint_metadata
self._browser_sign_in_success_delay = browser_sign_in_success_delay
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
@ -58,7 +59,11 @@ class OnboardingApp(App[str | None]):
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
)
self.install_screen(
ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata),
ApiKeyScreen(
self._provider,
vibe_base_url=self._vibe_base_url,
entrypoint_metadata=self._entrypoint_metadata,
),
"api_key",
)
if self._browser_sign_in_service_factory is not None:

View file

@ -12,6 +12,7 @@ from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
)
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
@ -29,6 +30,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
@ -97,6 +99,12 @@ def _load_onboarding_env_payload_for_fields(
and (models := _find_env_value("VIBE_MODELS")) is not None
):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
if (
"vibe_base_url" in field_names
and (vibe_base_url := _find_env_value("VIBE_VIBE_BASE_URL")) is not None
):
payload["vibe_base_url"] = vibe_base_url
return payload
@ -180,6 +188,7 @@ def _resolve_provider(
@dataclass(frozen=True)
class OnboardingContext:
provider: ProviderConfig
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
@property
def supports_browser_sign_in(self) -> bool:
@ -187,7 +196,9 @@ class OnboardingContext:
@classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext:
return cls(provider=config.get_active_provider())
return cls(
provider=config.get_active_provider(), vibe_base_url=config.vibe_base_url
)
@classmethod
def load(cls, **overrides: Any) -> OnboardingContext:
@ -198,7 +209,8 @@ class OnboardingContext:
return cls(
provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot
)
),
vibe_base_url=snapshot.vibe_base_url,
)
except (RuntimeError, ValidationError, ValueError):
logger.warning(

View file

@ -12,7 +12,7 @@ from textual.widgets import Input, Link
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ProviderConfig
from vibe.core.config import DEFAULT_VIBE_BASE_URL, ProviderConfig
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
@ -20,8 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
)
from vibe.setup.onboarding.base import OnboardingScreen
# providers with a dedicated key creation page get a direct onboarding link
PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")}
MISTRAL_PROVIDER_NAME = "mistral"
MISTRAL_PROVIDER_HELP_NAME = "Mistral Vibe"
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
)
@ -39,17 +39,19 @@ class ApiKeyScreen(OnboardingScreen):
self,
provider: ProviderConfig | None = None,
*,
vibe_base_url: str = DEFAULT_VIBE_BASE_URL,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
super().__init__()
self.provider = resolve_api_key_provider(provider)
self._vibe_base_url = vibe_base_url
self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self) -> ComposeResult:
if (help_info := PROVIDER_HELP.get(self.provider.name)) is None:
if self.provider.name != MISTRAL_PROVIDER_NAME:
return
help_url, _ = help_info
help_url = f"{self._vibe_base_url.rstrip('/')}/code/extensions?focus=key"
yield Link(help_url, url=help_url, id="api-key-provider-link")
def _compose_config_docs(self) -> ComposeResult:
@ -60,8 +62,11 @@ class ApiKeyScreen(OnboardingScreen):
def compose(self) -> ComposeResult:
provider_name = self.provider.name.capitalize()
help_info = PROVIDER_HELP.get(self.provider.name)
help_name = help_info[1] if help_info is not None else "your provider"
help_name = (
MISTRAL_PROVIDER_HELP_NAME
if self.provider.name == MISTRAL_PROVIDER_NAME
else "your provider"
)
self.input_widget = Input(
password=True,

View file

@ -1,4 +1,4 @@
# What's new in v2.12.1
# What's new in v2.13.0
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.