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

@ -17,7 +17,6 @@ body:
options: options:
- CLI - CLI
- ACP - ACP
- Both
- VS Code extension - VS Code extension
- Other - Other
validations: validations:

View file

@ -47,8 +47,8 @@ jobs:
labels.push('CLI'); labels.push('CLI');
} else if (component === 'ACP') { } else if (component === 'ACP') {
labels.push('ACP'); labels.push('ACP');
} else if (component === 'Both') { } else if (component === 'VS Code extension') {
labels.push('CLI', 'ACP'); labels.push('VS Code extension');
} }
if (labels.length > 0) { if (labels.length > 0) {

View file

@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.13.0] - 2026-05-29
### Added
- `enable_system_trust_store` config flag to switch the shared SSL context to the OS trust store for corporate TLS / private CA setups
### Changed
- MCP HTTP transport now uses Vibe's shared SSL context so it honors `SSL_CERT_FILE` / `SSL_CERT_DIR` and the system trust store opt-in
- API key onboarding and plan-upgrade CTAs now link to the new Mistral Vibe Code extensions page
- Compaction summaries are now injected into the conversation instead of replacing it
### Fixed
- Crash during initialization
- VS Code extension promo banner now sits at the top of the conversation instead of being pinned above the input
## [2.12.1] - 2026-05-27 ## [2.12.1] - 2026-05-27
### Fixed ### Fixed

View file

@ -425,6 +425,16 @@ Vibe supports multiple ways to configure your API keys:
**Note**: The `.env` file is specifically for API keys and other provider credentials. General Vibe configuration should be done in `config.toml`. **Note**: The `.env` file is specifically for API keys and other provider credentials. General Vibe configuration should be done in `config.toml`.
### TLS and Corporate Certificate Authorities
By default, Vibe uses the bundled `certifi` certificate roots for outbound HTTPS requests. If your organization installs private certificate authorities in the operating system trust store, you can opt in to the system trust store in `config.toml`:
```toml
enable_system_trust_store = true
```
`SSL_CERT_FILE` and `SSL_CERT_DIR` are still supported and are loaded as additional trust anchors.
### Custom System Prompts ### Custom System Prompts
You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt. You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt.

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.12.1" version = "2.13.0"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [agent_servers.mistral-vibe.targets.darwin-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-aarch64-2.12.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-aarch64-2.13.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-x86_64-2.12.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-x86_64-2.13.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-aarch64-2.12.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-aarch64-2.13.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-x86_64-2.12.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-x86_64-2.13.0.zip"
cmd = "./vibe-acp" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64] [agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-windows-x86_64-2.12.1.zip" archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-windows-x86_64-2.13.0.zip"
cmd = "./vibe-acp.exe" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.12.1" version = "2.13.0"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@ -64,7 +64,7 @@ dependencies = [
"linkify-it-py==2.1.0", "linkify-it-py==2.1.0",
"markdown-it-py==4.0.0", "markdown-it-py==4.0.0",
"markdownify==1.2.2", "markdownify==1.2.2",
"mcp==1.27.0", "mcp==1.27.1",
"mdit-py-plugins==0.5.0", "mdit-py-plugins==0.5.0",
"mdurl==0.1.2", "mdurl==0.1.2",
"mistralai==2.4.4", "mistralai==2.4.4",
@ -111,6 +111,7 @@ dependencies = [
"tomli-w==1.2.0", "tomli-w==1.2.0",
"tree-sitter==0.25.2", "tree-sitter==0.25.2",
"tree-sitter-bash==0.25.1", "tree-sitter-bash==0.25.1",
"truststore==0.10.4",
"typing-extensions==4.15.0", "typing-extensions==4.15.0",
"typing-inspection==0.4.2", "typing-inspection==0.4.2",
"uc-micro-py==2.0.0", "uc-micro-py==2.0.0",
@ -173,14 +174,65 @@ dev = [
build = [ build = [
"altgraph==0.17.5", "altgraph==0.17.5",
"anyio==4.13.0",
"certifi==2026.4.22",
"cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"cfgv==3.5.0",
"charset-normalizer==3.4.7",
"colorama==0.4.6 ; sys_platform == 'win32'",
"cryptography==47.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
"distlib==0.4.0",
"docutils==0.22.4",
"execnet==2.1.2",
"filelock==3.29.0",
"h11==0.16.0",
"httpcore==1.0.9",
"httpx==0.28.1",
"id==1.6.1",
"identify==2.6.19",
"idna==3.13",
"iniconfig==2.3.0",
"jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
"jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
"jaraco-functools==4.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
"jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
"jinja2==3.1.6",
"keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
"linkify-it-py==2.1.0",
"macholib==1.16.4 ; sys_platform == 'darwin'", "macholib==1.16.4 ; sys_platform == 'darwin'",
"markdown-it-py==4.0.0",
"markupsafe==3.0.3",
"mdit-py-plugins==0.5.0",
"mdurl==0.1.2",
"more-itertools==11.0.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
"nh3==0.3.5",
"nodeenv==1.10.0",
"packaging==26.2", "packaging==26.2",
"pefile==2024.8.26 ; sys_platform == 'win32'", "pefile==2024.8.26 ; sys_platform == 'win32'",
"platformdirs==4.9.6",
"pluggy==1.6.0",
"pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
"pygments==2.20.0",
"pyinstaller==6.20.0", "pyinstaller==6.20.0",
"pyinstaller-hooks-contrib==2026.4", "pyinstaller-hooks-contrib==2026.4",
"pyinstrument==5.1.2",
"python-discovery==1.2.2",
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'", "pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
"pyyaml==6.0.3",
"readme-renderer==44.0",
"requests==2.33.1",
"requests-toolbelt==1.0.0",
"rfc3986==2.0.0",
"rich==15.0.0",
"secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
"setuptools==82.0.1", "setuptools==82.0.1",
"syrupy==4.8.0",
"textual==8.2.7",
"truststore==0.10.4", "truststore==0.10.4",
"typing-extensions==4.15.0",
"uc-micro-py==2.0.0",
"urllib3==2.6.3",
"virtualenv==21.2.4",
] ]
[tool.pyright] [tool.pyright]

View file

@ -139,7 +139,7 @@ class TestACPAuthStatus:
assert response == { assert response == {
"authenticated": True, "authenticated": True,
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value, "authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
"signOutAvailable": False, "signOutAvailable": True,
} }
@pytest.mark.asyncio @pytest.mark.asyncio
@ -213,22 +213,23 @@ class TestACPAuthSignOut:
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key" assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refuses_combined_dotenv_and_process_env_key( async def test_removes_dotenv_key_and_restores_process_env_key(
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key") monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n") write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
acp_agent_loop = build_acp_agent_loop(build_mistral_provider()) acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
with pytest.raises( response = await acp_agent_loop.ext_method("auth/signOut", {})
InvalidRequestError,
match=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
):
await acp_agent_loop.ext_method("auth/signOut", {})
assert dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY] == ( assert response == {}
"file-key" assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
) assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
assert await acp_agent_loop.ext_method("auth/status", {}) == {
"authenticated": True,
"authState": AuthStateKind.PROCESS_ENV.value,
"signOutAvailable": False,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refuses_unsupported_provider_key( async def test_refuses_unsupported_provider_key(

View file

@ -62,7 +62,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
) )
assert response.auth_methods is not None assert response.auth_methods is not None
@ -94,7 +94,7 @@ class TestACPInitialize:
), ),
) )
assert response.agent_info == Implementation( assert response.agent_info == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1" name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
) )
assert response.auth_methods is not None assert response.auth_methods is not None

View file

@ -315,7 +315,7 @@ class TestAdapterPrepareRequest:
payload = json.loads(req.body) payload = json.loads(req.body)
assert payload["model"] == "claude-sonnet-4-20250514" assert payload["model"] == "claude-sonnet-4-20250514"
assert payload["max_tokens"] == 1024 assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5 assert "temperature" not in payload
assert req.endpoint == "/v1/messages" assert req.endpoint == "/v1/messages"
assert req.headers["anthropic-version"] == "2023-06-01" assert req.headers["anthropic-version"] == "2023-06-01"
@ -392,9 +392,10 @@ class TestAdapterPrepareRequest:
thinking="medium", thinking="medium",
) )
payload = json.loads(req.body) payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000} assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"}
assert payload["max_tokens"] == 1024 assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1 assert "temperature" not in payload
def test_system_cached(self, adapter, provider): def test_system_cached(self, adapter, provider):
messages = [ messages = [
@ -439,12 +440,8 @@ class TestAdapterPrepareRequest:
assert len(payload["tools"]) == 1 assert len(payload["tools"]) == 1
assert payload["tools"][0]["name"] == "test_tool" assert payload["tools"][0]["name"] == "test_tool"
@pytest.mark.parametrize( @pytest.mark.parametrize("level", ["low", "medium", "high", "max"])
"level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)] def test_thinking_levels(self, adapter, provider, level):
)
def test_thinking_levels_budget_model(
self, adapter, provider, level, expected_budget
):
messages = [LLMMessage(role=Role.user, content="Hello")] messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request( req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514", model_name="claude-sonnet-4-20250514",
@ -458,59 +455,12 @@ class TestAdapterPrepareRequest:
thinking=level, thinking=level,
) )
payload = json.loads(req.body) payload = json.loads(req.body)
assert payload["thinking"] == {
"type": "enabled",
"budget_tokens": expected_budget,
}
assert payload["temperature"] == 1
assert payload["max_tokens"] == expected_budget + 8192
@pytest.mark.parametrize(
"model_name", ["claude-opus-4-6-20260101", "claude-opus-4-7-20260418"]
)
@pytest.mark.parametrize("level", ["low", "medium", "high"])
def test_thinking_levels_adaptive_model(self, adapter, provider, model_name, level):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name=model_name,
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=level,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": level} assert payload["output_config"] == {"effort": level}
if "opus-4-7" in model_name:
assert "temperature" not in payload assert "temperature" not in payload
else:
assert payload["temperature"] == 1
assert payload["max_tokens"] == 32_768 assert payload["max_tokens"] == 32_768
@pytest.mark.parametrize("thinking_level", ["off", "low", "medium", "high"]) def test_history_forced_thinking(self, adapter, provider):
def test_temperature_omitted_for_deprecated_model(
self, adapter, provider, thinking_level
):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-opus-4-7-20260418",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
thinking=thinking_level,
)
payload = json.loads(req.body)
assert "temperature" not in payload
def test_history_forced_thinking_budget_model(self, adapter, provider):
messages = [ messages = [
LLMMessage(role=Role.user, content="Hello"), LLMMessage(role=Role.user, content="Hello"),
LLMMessage( LLMMessage(
@ -532,34 +482,9 @@ class TestAdapterPrepareRequest:
provider=provider, provider=provider,
) )
payload = json.loads(req.body) payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000}
assert payload["temperature"] == 1
assert payload["max_tokens"] == 18_192
def test_history_forced_thinking_adaptive_model(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
role=Role.assistant,
content="Answer",
reasoning_content="thinking...",
reasoning_signature="sig",
),
LLMMessage(role=Role.user, content="Follow up"),
]
req = adapter.prepare_request(
model_name="claude-opus-4-6-20260101",
messages=messages,
temperature=0.5,
tools=None,
max_tokens=None,
tool_choice=None,
enable_streaming=False,
provider=provider,
)
payload = json.loads(req.body)
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"} assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"} assert payload["output_config"] == {"effort": "medium"}
assert "temperature" not in payload
assert payload["max_tokens"] == 32_768 assert payload["max_tokens"] == 32_768

View file

@ -0,0 +1,140 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from ipaddress import IPv4Address
from pathlib import Path
import ssl
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
import pytest
from tests.e2e.mock_server import StreamingMockServer
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.types import Backend, LLMMessage, Role
from vibe.core.utils import build_ssl_context, configure_ssl_context
@dataclass(frozen=True)
class _TLSMaterial:
ca_file: str
cert_file: str
key_file: str
@dataclass(frozen=True)
class _HttpsStreamingMockServer:
server: StreamingMockServer
ca_file: str
@pytest.fixture
def https_streaming_mock_server(tmp_path: Path) -> Iterator[_HttpsStreamingMockServer]:
tls = _write_tls_material(tmp_path)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(certfile=tls.cert_file, keyfile=tls.key_file)
server = StreamingMockServer(ssl_context=ssl_context)
server.start()
try:
yield _HttpsStreamingMockServer(server=server, ca_file=tls.ca_file)
finally:
server.stop()
@pytest.mark.asyncio
async def test_generic_backend_streaming_uses_ssl_cert_file(
monkeypatch: pytest.MonkeyPatch,
https_streaming_mock_server: _HttpsStreamingMockServer,
) -> None:
monkeypatch.setenv("SSL_CERT_FILE", https_streaming_mock_server.ca_file)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
chunks = []
try:
provider = ProviderConfig(
name="mock-provider",
api_base=https_streaming_mock_server.server.api_base,
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.GENERIC,
)
model = ModelConfig(
name="mock-model", provider="mock-provider", alias="mock-model"
)
async with GenericBackend(provider=provider, timeout=5.0) as backend:
chunks = [
chunk
async for chunk in backend.complete_streaming(
model=model, messages=[LLMMessage(role=Role.user, content="Greet")]
)
]
finally:
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
content = "".join(chunk.message.content or "" for chunk in chunks)
request_payload = https_streaming_mock_server.server.requests[-1]
assert content == "Hello from mock server"
assert request_payload.get("stream") is True
assert request_payload.get("model") == "mock-model"
def _write_tls_material(tmp_path: Path) -> _TLSMaterial:
now = datetime.now(UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Vibe test CA")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
server_cert = (
x509.CertificateBuilder()
.subject_name(server_name)
.issuer_name(ca_cert.subject)
.public_key(server_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(IPv4Address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False
)
.sign(ca_key, hashes.SHA256())
)
ca_file = tmp_path / "ca.pem"
cert_file = tmp_path / "server.pem"
key_file = tmp_path / "server.key"
ca_file.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
cert_file.write_bytes(server_cert.public_bytes(serialization.Encoding.PEM))
key_file.write_bytes(
server_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
return _TLSMaterial(
ca_file=str(ca_file), cert_file=str(cert_file), key_file=str(key_file)
)

View file

@ -91,7 +91,7 @@ class TestPrepareRequest:
assert payload["anthropic_version"] == "vertex-2023-10-16" assert payload["anthropic_version"] == "vertex-2023-10-16"
assert "model" not in payload assert "model" not in payload
assert payload["max_tokens"] == 1024 assert payload["max_tokens"] == 1024
assert payload["temperature"] == 0.5 assert "temperature" not in payload
assert req.headers["Authorization"] == "Bearer fake-token" assert req.headers["Authorization"] == "Bearer fake-token"
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
assert "rawPredict" in req.endpoint assert "rawPredict" in req.endpoint
@ -147,9 +147,10 @@ class TestPrepareRequest:
) )
payload = json.loads(req.body) payload = json.loads(req.body)
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000} assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
assert payload["output_config"] == {"effort": "medium"}
assert payload["max_tokens"] == 1024 assert payload["max_tokens"] == 1024
assert payload["temperature"] == 1 assert "temperature" not in payload
def test_with_tools(self, adapter, provider): def test_with_tools(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")] messages = [LLMMessage(role=Role.user, content="Hello")]

View file

@ -11,6 +11,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo, PlanInfo,
WhoAmIPlanType, WhoAmIPlanType,
decide_plan_offer, decide_plan_offer,
plan_offer_cta,
resolve_api_key_for_plan, resolve_api_key_for_plan,
) )
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@ -174,6 +175,47 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
environ["MISTRAL_API_KEY"] = previous_api_key environ["MISTRAL_API_KEY"] = previous_api_key
@pytest.mark.parametrize(
("plan_info", "expected_cta"),
[
(
PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
),
"### Switch to your [Vibe Pro API key](https://chat.mistral.ai/code/extensions?focus=key)",
),
(
PlanInfo(
plan_type=WhoAmIPlanType.API,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
"### Unlock more with Vibe - [Upgrade to Vibe Pro](https://chat.mistral.ai/code/extensions?focus=key)",
),
],
ids=["switch-to-vibe-pro-key", "upgrade-to-vibe-pro"],
)
def test_plan_offer_cta_routes_users_to_vibe_api_key_extensions(
plan_info: PlanInfo, expected_cta: str
) -> None:
assert plan_offer_cta(plan_info) == expected_cta
def test_plan_offer_cta_uses_configured_vibe_url() -> None:
plan_info = PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
)
assert (
plan_offer_cta(plan_info, vibe_base_url="https://vibe.example.com/")
== "### Switch to your [Vibe Pro API key](https://vibe.example.com/code/extensions?focus=key)"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("response", "expected"), ("response", "expected"),
[ [

View file

@ -12,14 +12,12 @@ class TestCompactMessage:
message.post_message = MagicMock() message.post_message = MagicMock()
message.set_complete( message.set_complete(
old_tokens=177_017,
new_tokens=23_263,
old_session_id="11111111-1111-1111-1111-111111111111", old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222", new_session_id="22222222-2222-2222-2222-222222222222",
) )
assert message.get_content() == ( assert message.get_content() == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n" "Compaction completed.\n"
"session: " "session: "
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} " f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
"(before compaction) → " "(before compaction) → "

View file

@ -5,13 +5,15 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from vibe.core.utils.http import build_ssl_context from vibe.core.utils.http import build_ssl_context, configure_ssl_context
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_ssl_cache(): def _clear_ssl_cache():
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear() build_ssl_context.cache_clear()
yield yield
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear() build_ssl_context.cache_clear()
@ -20,6 +22,42 @@ def test_build_ssl_context_returns_ssl_context():
assert isinstance(ctx, ssl.SSLContext) assert isinstance(ctx, ssl.SSLContext)
def test_build_ssl_context_uses_certifi_by_default(monkeypatch):
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with (
patch("vibe.core.utils.http.certifi.where", return_value="/certifi.pem"),
patch(
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
) as create_default_context,
):
build_ssl_context()
create_default_context.assert_called_once_with(cafile="/certifi.pem")
def test_build_ssl_context_uses_system_trust_store_when_configured(monkeypatch):
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=True)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with (
patch(
"vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx
) as truststore_context,
patch(
"vibe.core.utils.http.ssl.create_default_context"
) as create_default_context,
):
build_ssl_context()
truststore_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT)
create_default_context.assert_not_called()
def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path): def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
cert_file = tmp_path / "custom.pem" cert_file = tmp_path / "custom.pem"
cert_file.write_text("dummy") cert_file.write_text("dummy")
@ -37,6 +75,24 @@ def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
) )
def test_build_ssl_context_loads_custom_cert_file_with_system_trust_store(
monkeypatch, tmp_path
):
cert_file = tmp_path / "custom.pem"
cert_file.write_text("dummy")
monkeypatch.setenv("SSL_CERT_FILE", str(cert_file))
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=True)
mock_ctx = MagicMock(spec=ssl.SSLContext)
with patch("vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx):
build_ssl_context()
mock_ctx.load_verify_locations.assert_called_once_with(
cafile=str(cert_file), capath=None
)
def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path): def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path):
cert_dir = tmp_path / "certs" cert_dir = tmp_path / "certs"
cert_dir.mkdir() cert_dir.mkdir()

View file

@ -2,8 +2,10 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import ssl
import tomllib import tomllib
from typing import Literal, TypedDict, Unpack from typing import Literal, TypedDict, Unpack
from unittest.mock import MagicMock, patch
import pytest import pytest
import tomli_w import tomli_w
@ -23,6 +25,7 @@ from vibe.core.config.harness_files import (
from vibe.core.paths import VIBE_HOME from vibe.core.paths import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.types import Backend from vibe.core.types import Backend
from vibe.core.utils.http import build_ssl_context, configure_ssl_context
from vibe.setup.onboarding.context import OnboardingContext from vibe.setup.onboarding.context import OnboardingContext
@ -198,6 +201,73 @@ class TestSaveUpdates:
assert result == {"tools": {"bash": {"default_timeout": 600}}} assert result == {"tools": {"bash": {"default_timeout": 600}}}
class TestSystemTrustStoreConfig:
def test_load_configures_ssl_context_from_toml(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
with config_file.open("rb") as f:
data = tomllib.load(f)
data["enable_system_trust_store"] = True
with config_file.open("wb") as f:
tomli_w.dump(data, f)
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
config = VibeConfig.load()
assert config.enable_system_trust_store is True
configure.assert_called_once_with(enable_system_trust_store=True)
def test_load_configures_ssl_context_from_env(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_ENABLE_SYSTEM_TRUST_STORE", "true")
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
config = VibeConfig.load()
assert config.enable_system_trust_store is True
configure.assert_called_once_with(enable_system_trust_store=True)
def test_load_clears_cached_ssl_context_when_setting_changes(
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
try:
config_file = config_dir / "config.toml"
with config_file.open("rb") as f:
data = tomllib.load(f)
data["enable_system_trust_store"] = False
with config_file.open("wb") as f:
tomli_w.dump(data, f)
default_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.ssl.create_default_context",
return_value=default_ctx,
):
VibeConfig.load()
assert build_ssl_context() is default_ctx
data["enable_system_trust_store"] = True
with config_file.open("wb") as f:
tomli_w.dump(data, f)
truststore_ctx = MagicMock(spec=ssl.SSLContext)
with patch(
"vibe.core.utils.http.truststore.SSLContext",
return_value=truststore_ctx,
):
VibeConfig.load()
assert build_ssl_context() is truststore_ctx
finally:
configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
class TestSetThinking: class TestSetThinking:
def test_persists_thinking_to_toml(self, config_dir: Path) -> None: def test_persists_thinking_to_toml(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml" config_file = config_dir / "config.toml"
@ -821,6 +891,7 @@ class TestOnboardingContextResolution:
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
reset_harness_files_manager() reset_harness_files_manager()
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model") monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
monkeypatch.setenv("VIBE_VIBE_BASE_URL", "https://env-vibe.example.com")
monkeypatch.setenv( monkeypatch.setenv(
"VIBE_PROVIDERS", "VIBE_PROVIDERS",
json.dumps([ json.dumps([
@ -842,6 +913,7 @@ class TestOnboardingContextResolution:
assert context.provider.name == "env-provider" assert context.provider.name == "env-provider"
assert context.provider.api_key_env_var == "ENV_API_KEY" assert context.provider.api_key_env_var == "ENV_API_KEY"
assert context.vibe_base_url == "https://env-vibe.example.com"
def test_load_prefers_explicit_overrides_over_toml_and_env( def test_load_prefers_explicit_overrides_over_toml_and_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View file

@ -331,17 +331,13 @@ class TestTelemetryClient:
client = TelemetryClient(config_getter=lambda: config) client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered( client.send_auto_compact_triggered(
nb_context_tokens_before=123, nb_context_tokens_before=123, auto_compact_threshold=100, status="success"
nb_context_tokens_after=45,
auto_compact_threshold=100,
status="success",
) )
assert len(telemetry_events) == 1 assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered" assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
assert telemetry_events[0]["properties"] == { assert telemetry_events[0]["properties"] == {
"nb_context_tokens_before": 123, "nb_context_tokens_before": 123,
"nb_context_tokens_after": 45,
"auto_compact_threshold": 100, "auto_compact_threshold": 100,
"status": "success", "status": "success",
} }
@ -674,7 +670,6 @@ class TestTelemetryClient:
client.send_auto_compact_triggered( client.send_auto_compact_triggered(
nb_context_tokens_before=123, nb_context_tokens_before=123,
nb_context_tokens_after=45,
auto_compact_threshold=100, auto_compact_threshold=100,
status="success", status="success",
session_id="original-session-id", session_id="original-session-id",

View file

@ -4,7 +4,7 @@ from pathlib import Path
import pytest import pytest
from vibe.core.utils import compact_reduction_display, get_server_url_from_api_base from vibe.core.utils import compact_complete_display, get_server_url_from_api_base
import vibe.core.utils.io as io_utils import vibe.core.utils.io as io_utils
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
@ -23,22 +23,18 @@ def test_get_server_url_from_api_base(api_base, expected):
assert get_server_url_from_api_base(api_base) == expected assert get_server_url_from_api_base(api_base) == expected
class TestCompactReductionDisplay: class TestCompactCompleteDisplay:
def test_includes_session_ids_when_available(self) -> None: def test_includes_session_ids_when_available(self) -> None:
assert compact_reduction_display( assert compact_complete_display(
177_017,
23_263,
old_session_id="11111111-1111-1111-1111-111111111111", old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222", new_session_id="22222222-2222-2222-2222-222222222222",
) == ( ) == (
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n" "Compaction completed.\n"
"session: 11111111 (before compaction) → 22222222 (after compaction)" "session: 11111111 (before compaction) → 22222222 (after compaction)"
) )
def test_returns_base_message_without_session_ids(self) -> None: def test_returns_base_message_without_session_ids(self) -> None:
assert compact_reduction_display(177_017, 23_263) == ( assert compact_complete_display() == "Compaction completed."
"Compaction complete: 177,017 → 23,263 tokens (-87.%)"
)
class TestReadSafe: class TestReadSafe:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json import json
import ssl
import threading import threading
import time import time
from typing import TypedDict, cast from typing import TypedDict, cast
@ -84,11 +85,21 @@ class StreamingMockServer:
), ),
] ]
def __init__(self, *, chunk_factory: ChunkFactory | None = None) -> None: def __init__(
self,
*,
chunk_factory: ChunkFactory | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
self.requests: list[ChatCompletionsRequestPayload] = [] self.requests: list[ChatCompletionsRequestPayload] = []
self._lock = threading.Lock() self._lock = threading.Lock()
self._chunk_factory = chunk_factory self._chunk_factory = chunk_factory
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler()) self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
self._scheme = "https" if ssl_context is not None else "http"
if ssl_context is not None:
self._server.socket = ssl_context.wrap_socket(
self._server.socket, server_side=True
)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
def _build_handler(self) -> type[BaseHTTPRequestHandler]: def _build_handler(self) -> type[BaseHTTPRequestHandler]:
@ -138,7 +149,7 @@ class StreamingMockServer:
@property @property
def api_base(self) -> str: def api_base(self) -> str:
return f"http://127.0.0.1:{self._server.server_port}/v1" return f"{self._scheme}://127.0.0.1:{self._server.server_port}/v1"
def start(self) -> None: def start(self) -> None:
self._thread.start() self._thread.start()

View file

@ -12,7 +12,7 @@ from textual.geometry import Size
from textual.pilot import Pilot from textual.pilot import Pilot
from textual.screen import Screen from textual.screen import Screen
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Input from textual.widgets import Input, Link
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
from tests.conftest import build_test_vibe_config from tests.conftest import build_test_vibe_config
@ -70,6 +70,7 @@ def _build_onboarding_config(
api_key_env_var: str = "MISTRAL_API_KEY", api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str | None = None, browser_auth_base_url: str | None = None,
browser_auth_api_base_url: str | None = None, browser_auth_api_base_url: str | None = None,
vibe_base_url: str = "https://chat.mistral.ai",
) -> VibeConfig: ) -> VibeConfig:
provider = ProviderConfig( provider = ProviderConfig(
name=provider_name, name=provider_name,
@ -84,7 +85,9 @@ def _build_onboarding_config(
provider=model_provider or provider_name, provider=model_provider or provider_name,
alias="devstral-2", alias="devstral-2",
) )
return build_test_vibe_config(providers=[provider], models=[model]) return build_test_vibe_config(
providers=[provider], models=[model], vibe_base_url=vibe_base_url
)
def _build_browser_onboarding_app( def _build_browser_onboarding_app(
@ -236,6 +239,12 @@ async def _show_browser_sign_in(pilot: Pilot) -> None:
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot) await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
async def _show_manual_api_key_screen(pilot: Pilot) -> None:
await _show_auth_method(pilot)
await pilot.press("down", "enter")
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None: async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
app = OnboardingApp( app = OnboardingApp(
@ -892,6 +901,20 @@ def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY" assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
@pytest.mark.asyncio
async def test_ui_manual_api_key_screen_uses_configured_vibe_url() -> None:
app = OnboardingApp(
config=_build_onboarding_config(vibe_base_url="https://vibe.example.com/")
)
async with app.run_test() as pilot:
await _show_manual_api_key_screen(pilot)
provider_link = app.screen.query_one("#api-key-provider-link", Link)
assert provider_link.url == "https://vibe.example.com/code/extensions?focus=key"
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None: def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
provider = ProviderConfig( provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME" name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"

View file

@ -116,7 +116,7 @@ def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist
assert state == AuthState( assert state == AuthState(
kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV, kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True, can_use_active_provider=True,
sign_out_available=False, sign_out_available=True,
env_key=DEFAULT_MISTRAL_API_ENV_KEY, env_key=DEFAULT_MISTRAL_API_ENV_KEY,
) )

View file

@ -129,13 +129,13 @@
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)"> <text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)"> </text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)"> </text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">&#160;⢠⢢&#160;&#160;&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)"> </text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">&#160;&#160;⡠⣒⠄&#160;&#160;⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
</text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">&#160;⢸⣀⡔⢉⠱⣃⡐⣔</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)"> </text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)">&#160;⠸⣀⡔⢉⠱⣃⡢⣂</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
</text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">&#160;⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)"> </text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">&#160;&#160;⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
</text><text class="terminal-r2" x="48.8" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">Get&#160;your&#160;Mistral&#160;API&#160;key</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)"> </text><text class="terminal-r2" x="48.8" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">Get&#160;your&#160;Mistral&#160;API&#160;key</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
</text><text class="terminal-r3" x="48.8" y="190.8" textLength="597.8" clip-path="url(#terminal-line-7)">Visit&#160;AI&#160;Studio&#160;to&#160;generate&#160;or&#160;copy&#160;your&#160;Vibe&#160;key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)"> </text><text class="terminal-r3" x="48.8" y="190.8" textLength="634.4" clip-path="url(#terminal-line-7)">Visit&#160;Mistral&#160;Vibe&#160;to&#160;generate&#160;or&#160;copy&#160;your&#160;Vibe&#160;key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)"> </text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="488" clip-path="url(#terminal-line-9)">https://console.mistral.ai/codestral/cli</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)"> </text><text class="terminal-r4" x="48.8" y="239.6" textLength="597.8" clip-path="url(#terminal-line-9)">https://chat.mistral.ai/code/extensions?focus=key</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)"> </text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)"> </text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
</text><text class="terminal-r5" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">┌─</text><text class="terminal-r3" x="73.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">&#160;Paste&#160;API&#160;key&#160;</text><text class="terminal-r5" x="256.2" y="312.8" textLength="695.4" clip-path="url(#terminal-line-12)">────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)"> </text><text class="terminal-r5" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">┌─</text><text class="terminal-r3" x="73.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)">&#160;Paste&#160;API&#160;key&#160;</text><text class="terminal-r5" x="256.2" y="312.8" textLength="695.4" clip-path="url(#terminal-line-12)">────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch&#160;to&#160;your&#160;</text><text class="terminal-r7" x="207.4" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Le&#160;Chat&#160;Pro&#160;API&#160;key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch&#160;to&#160;your&#160;</text><text class="terminal-r7" x="207.4" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Vibe&#160;Pro&#160;API&#160;key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -189,7 +189,7 @@
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"> </text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)"></text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">&#160;Feature&#160;2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"> </text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)"></text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">&#160;Feature&#160;3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"> </text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)"></text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock&#160;more&#160;with&#160;Vibe&#160;-&#160;</text><text class="terminal-r7" x="317.2" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">Upgrade&#160;to&#160;Le&#160;Chat&#160;Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"> </text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)"></text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock&#160;more&#160;with&#160;Vibe&#160;-&#160;</text><text class="terminal-r7" x="317.2" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Upgrade&#160;to&#160;Vibe&#160;Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)"> </text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)"> </text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)"> </text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -1,14 +1,23 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
import pytest
from textual.app import App from textual.app import App
from textual.pilot import Pilot from textual.pilot import Pilot
from tests.snapshots.snap_compare import SnapCompare from tests.snapshots.snap_compare import SnapCompare
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.core.config import ProviderConfig from vibe.core.config import ProviderConfig
from vibe.core.types import Backend from vibe.core.types import Backend
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
class StaticPetitChat(PetitChat):
def __init__(self, **kwargs: Any) -> None:
super().__init__(animate=False, **kwargs)
class ApiKeyScreenSnapshotApp(App[str | None]): class ApiKeyScreenSnapshotApp(App[str | None]):
CSS_PATH = "../../vibe/setup/onboarding/onboarding.tcss" CSS_PATH = "../../vibe/setup/onboarding/onboarding.tcss"
@ -27,8 +36,12 @@ class ApiKeyScreenSnapshotApp(App[str | None]):
def test_snapshot_onboarding_api_key_with_valid_input( def test_snapshot_onboarding_api_key_with_valid_input(
snap_compare: SnapCompare, snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
monkeypatch.setattr(
"vibe.setup.onboarding.screens.api_key.PetitChat", StaticPetitChat
)
async def run_before(pilot: Pilot) -> None: async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2) await pilot.pause(0.2)
await pilot.press(*"sk-test-api-key") await pilot.press(*"sk-test-api-key")

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence from collections.abc import AsyncGenerator, Iterable
from typing import cast from typing import cast
from tests.mock.utils import mock_llm_chunk from tests.mock.utils import mock_llm_chunk
@ -21,7 +21,6 @@ class FakeBackend:
| Iterable[Iterable[LLMChunk]] | Iterable[Iterable[LLMChunk]]
| None = None, | None = None,
*, *,
token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
exception_to_raise: Exception | None = None, exception_to_raise: Exception | None = None,
) -> None: ) -> None:
"""Fake backend that will output the given chunks in the order they are given. """Fake backend that will output the given chunks in the order they are given.
@ -36,8 +35,6 @@ class FakeBackend:
self._requests_messages: list[list[LLMMessage]] = [] self._requests_messages: list[list[LLMMessage]] = []
self._requests_extra_headers: list[dict[str, str] | None] = [] self._requests_extra_headers: list[dict[str, str] | None] = []
self._requests_metadata: list[dict[str, str] | None] = [] self._requests_metadata: list[dict[str, str] | None] = []
self._count_tokens_calls: list[list[LLMMessage]] = []
self._token_counter = token_counter or self._default_token_counter
self._exception_to_raise = exception_to_raise self._exception_to_raise = exception_to_raise
self._streams: list[list[LLMChunk]] self._streams: list[list[LLMChunk]]
@ -70,10 +67,6 @@ class FakeBackend:
def requests_metadata(self) -> list[dict[str, str] | None]: def requests_metadata(self) -> list[dict[str, str] | None]:
return self._requests_metadata return self._requests_metadata
@staticmethod
def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
return 1
async def __aenter__(self): async def __aenter__(self):
return self return self
@ -133,20 +126,3 @@ class FakeBackend:
stream = [mock_llm_chunk(content="")] stream = [mock_llm_chunk(content="")]
for chunk in stream: for chunk in stream:
yield chunk yield chunk
async def count_tokens(
self,
*,
model,
messages,
temperature=0.0,
tools,
tool_choice=None,
extra_headers,
metadata=None,
) -> int:
self._requests_messages.append(list(messages))
self._requests_extra_headers.append(extra_headers)
self._requests_metadata.append(metadata)
self._count_tokens_calls.append(list(messages))
return self._token_counter(messages)

View file

@ -60,13 +60,11 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -
final: AssistantEvent = events[3] final: AssistantEvent = events[3]
assert start.current_context_tokens == 2 assert start.current_context_tokens == 2
assert start.threshold == 1 assert start.threshold == 1
assert end.old_context_tokens == 2 assert isinstance(end, CompactEndEvent)
assert end.new_context_tokens >= 1
assert final.content == "<final>" assert final.content == "<final>"
properties = _get_auto_compact_properties(telemetry_events) properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2 assert properties["nb_context_tokens_before"] == 2
assert properties["nb_context_tokens_after"] == end.new_context_tokens
assert properties["auto_compact_threshold"] == 1 assert properties["auto_compact_threshold"] == 1
assert properties["status"] == "success" assert properties["status"] == "success"
assert properties["session_id"] == old_session_id assert properties["session_id"] == old_session_id
@ -119,7 +117,6 @@ async def test_auto_compact_emits_terminal_telemetry(
properties = _get_auto_compact_properties(telemetry_events) properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2 assert properties["nb_context_tokens_before"] == 2
assert properties["nb_context_tokens_after"] == 2
assert properties["auto_compact_threshold"] == 1 assert properties["auto_compact_threshold"] == 1
assert properties["status"] == expected_status assert properties["status"] == expected_status
assert properties["session_id"] == old_session_id assert properties["session_id"] == old_session_id

View file

@ -335,8 +335,8 @@ async def test_mistral_metadata_header_call_type_per_turn() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> None: async def test_auto_compact_emits_summary_and_next_turn_metadata() -> None:
"""Compact emits summary, token-count, then user-turn backend metadata in order.""" """Compact emits summary then user-turn backend metadata in order."""
backend = FakeBackend([ backend = FakeBackend([
[mock_llm_chunk(content="<summary>")], [mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")], [mock_llm_chunk(content="<final>")],
@ -358,32 +358,24 @@ async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> No
[_ async for _ in agent.act("Hello")] [_ async for _ in agent.act("Hello")]
assert len(backend.requests_metadata) == 3 assert len(backend.requests_metadata) == 2
assert len(backend.requests_extra_headers) == 3 assert len(backend.requests_extra_headers) == 2
compact_metadata = backend.requests_metadata[0] compact_metadata = backend.requests_metadata[0]
recount_metadata = backend.requests_metadata[1] user_turn_metadata = backend.requests_metadata[1]
user_turn_metadata = backend.requests_metadata[2]
assert compact_metadata is not None assert compact_metadata is not None
assert recount_metadata is not None
assert user_turn_metadata is not None assert user_turn_metadata is not None
assert compact_metadata["call_type"] == "secondary_call" assert compact_metadata["call_type"] == "secondary_call"
assert compact_metadata["session_id"] == original_session_id assert compact_metadata["session_id"] == original_session_id
assert "parent_session_id" not in compact_metadata assert "parent_session_id" not in compact_metadata
assert recount_metadata["call_type"] == "secondary_call"
assert recount_metadata["session_id"] == agent.session_id
assert recount_metadata["parent_session_id"] == original_session_id
assert user_turn_metadata["call_type"] == "main_call" assert user_turn_metadata["call_type"] == "main_call"
assert user_turn_metadata["session_id"] == agent.session_id assert user_turn_metadata["session_id"] == agent.session_id
assert user_turn_metadata["parent_session_id"] == original_session_id assert user_turn_metadata["parent_session_id"] == original_session_id
compact_headers = backend.requests_extra_headers[0] compact_headers = backend.requests_extra_headers[0]
recount_headers = backend.requests_extra_headers[1] user_turn_headers = backend.requests_extra_headers[1]
user_turn_headers = backend.requests_extra_headers[2]
assert compact_headers is not None assert compact_headers is not None
assert recount_headers is not None
assert user_turn_headers is not None assert user_turn_headers is not None
assert compact_headers["x-affinity"] == original_session_id assert compact_headers["x-affinity"] == original_session_id
assert recount_headers["x-affinity"] == agent.session_id
assert user_turn_headers["x-affinity"] == agent.session_id assert user_turn_headers["x-affinity"] == agent.session_id

View file

@ -526,13 +526,10 @@ class TestAutoCompactIntegration:
assert isinstance(events[3], AssistantEvent) assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[1] start: CompactStartEvent = events[1]
end: CompactEndEvent = events[2]
final: AssistantEvent = events[3] final: AssistantEvent = events[3]
assert start.current_context_tokens == 2 assert start.current_context_tokens == 2
assert start.threshold == 1 assert start.threshold == 1
assert end.old_context_tokens == 2
assert end.new_context_tokens >= 1
assert final.content == "<final>" assert final.content == "<final>"
roles = [r for r, _ in observed] roles = [r for r, _ in observed]

View file

@ -481,6 +481,54 @@ class TestACloseCancelsExperimentsTask:
assert not task.cancelled() assert not task.cancelled()
class TestCycleAgentDuringInit:
@pytest.mark.asyncio
async def test_shift_tab_during_experiments_init_does_not_crash(self) -> None:
"""Regression: shift+tab during init crashed with
RuntimeError("await wasn't used with future").
_cycle_agent ran switch_agent via asyncio.run() in a thread worker,
creating a second event loop. wait_until_ready then tried to await
_experiments_task (owned by the main Textual loop) from that new loop.
The fix uses asyncio.run_coroutine_threadsafe() to schedule on the
main loop instead. This test presses shift+tab while experiments
are still initializing and asserts the worker completes without error.
"""
from tests.conftest import build_test_vibe_app
gate = asyncio.Event()
async def slow_init() -> None:
await gate.wait()
agent_loop = build_test_agent_loop()
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
with patch.object(
agent_loop, "initialize_experiments", side_effect=slow_init
):
agent_loop._experiments_task = None # reset so start_ re-fires
agent_loop.start_initialize_experiments()
assert agent_loop._experiments_task is not None
assert not agent_loop._experiments_task.done()
# Press shift+tab while experiments are still running.
await pilot.press("shift+tab")
await pilot.pause(0.05)
# Unblock experiments so switch_agent can complete.
gate.set()
# wait_for_complete raises WorkerFailed if the thread worker
# crashed — which is exactly what happened before the fix.
await pilot.app.workers.wait_for_complete()
assert agent_loop.agent_profile.name == "plan"
class TestActGatesOnExperiments: class TestActGatesOnExperiments:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_act_awaits_experiments_before_llm_call(self) -> None: async def test_act_awaits_experiments_before_llm_call(self) -> None:

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import os import os
import threading import threading
import time import time
from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -20,9 +22,12 @@ from vibe.core.tools.mcp import (
_mcp_stderr_capture, _mcp_stderr_capture,
_parse_call_result, _parse_call_result,
_stderr_logger_thread, _stderr_logger_thread,
call_tool_http,
call_tool_stdio, call_tool_stdio,
create_mcp_http_proxy_tool_class, create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class, create_mcp_stdio_proxy_tool_class,
create_vibe_mcp_http_client,
list_tools_http,
list_tools_stdio, list_tools_stdio,
) )
@ -133,6 +138,130 @@ class TestParseCallResult:
assert result.text == "line1\nline2" assert result.text == "line1\nline2"
class TestMCPHttpClient:
def test_create_vibe_mcp_http_client_uses_vibe_ssl_context(self):
headers = {"Authorization": "Bearer token"}
ssl_context = object()
fake_client = object()
with (
patch(
"vibe.core.tools.mcp.tools.build_ssl_context", return_value=ssl_context
),
patch(
"vibe.core.tools.mcp.tools.httpx.AsyncClient", return_value=fake_client
) as async_client,
):
client = create_vibe_mcp_http_client(headers)
assert client is fake_client
kwargs = async_client.call_args.kwargs
assert kwargs["follow_redirects"] is True
assert kwargs["headers"] == headers
assert kwargs["verify"] is ssl_context
assert kwargs["timeout"].connect == 30.0
assert kwargs["timeout"].read == 300.0
@pytest.mark.asyncio
async def test_list_tools_http_uses_vibe_mcp_http_client(self):
fake_client = _FakeHttpClient()
captured: dict[str, Any] = {}
@contextlib.asynccontextmanager
async def fake_stream(url: str, *, http_client: Any):
captured["url"] = url
captured["http_client"] = http_client
yield object(), object(), lambda: None
with (
patch(
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
return_value=fake_client,
) as create_client,
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
):
tools = await list_tools_http(
"https://mcp.example.com",
headers={"Authorization": "Bearer token"},
startup_timeout_sec=42.0,
)
create_client.assert_called_once_with({"Authorization": "Bearer token"})
assert fake_client.entered is True
assert fake_client.closed is True
assert captured["url"] == "https://mcp.example.com"
assert captured["http_client"] is fake_client
assert [tool.name for tool in tools] == ["remote_tool"]
@pytest.mark.asyncio
async def test_call_tool_http_uses_vibe_mcp_http_client(self):
fake_client = _FakeHttpClient()
captured: dict[str, Any] = {}
@contextlib.asynccontextmanager
async def fake_stream(url: str, *, http_client: Any):
captured["url"] = url
captured["http_client"] = http_client
yield object(), object(), lambda: None
with (
patch(
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
return_value=fake_client,
) as create_client,
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
):
result = await call_tool_http(
"https://mcp.example.com",
"remote_tool",
{"query": "hello"},
headers={"Authorization": "Bearer token"},
startup_timeout_sec=42.0,
tool_timeout_sec=12.0,
)
create_client.assert_called_once_with({"Authorization": "Bearer token"})
assert fake_client.entered is True
assert fake_client.closed is True
assert captured["url"] == "https://mcp.example.com"
assert captured["http_client"] is fake_client
assert result.structured == {"ok": True}
class _FakeHttpClient:
def __init__(self) -> None:
self.entered = False
self.closed = False
async def __aenter__(self) -> _FakeHttpClient:
self.entered = True
return self
async def __aexit__(self, *_: Any) -> None:
self.closed = True
class _FakeMCPClientSession:
def __init__(self, *_: Any, **__: Any) -> None:
pass
async def __aenter__(self) -> _FakeMCPClientSession:
return self
async def __aexit__(self, *_: Any) -> None:
pass
async def initialize(self) -> None:
pass
async def list_tools(self) -> SimpleNamespace:
return SimpleNamespace(tools=[{"name": "remote_tool"}])
async def call_tool(self, *_: Any, **__: Any) -> SimpleNamespace:
return SimpleNamespace(structuredContent={"ok": True}, content=None)
class TestMCPStderrCapture: class TestMCPStderrCapture:
"""Tests for _mcp_stderr_capture and _stderr_logger_thread.""" """Tests for _mcp_stderr_capture and _stderr_logger_thread."""

116
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "2026-05-20T19:55:04.890389Z" exclude-newer = "2026-05-22T14:05:58.077106Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [options.exclude-newer-package]
@ -775,7 +775,7 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.27.0" version = "1.27.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -793,9 +793,9 @@ dependencies = [
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" },
] ]
[[package]] [[package]]
@ -821,7 +821,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.12.1" version = "2.13.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },
@ -908,6 +908,7 @@ dependencies = [
{ name = "tomli-w" }, { name = "tomli-w" },
{ name = "tree-sitter" }, { name = "tree-sitter" },
{ name = "tree-sitter-bash" }, { name = "tree-sitter-bash" },
{ name = "truststore" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
{ name = "uc-micro-py" }, { name = "uc-micro-py" },
@ -922,14 +923,65 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
build = [ build = [
{ name = "altgraph" }, { name = "altgraph" },
{ name = "anyio" },
{ name = "certifi" },
{ name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
{ name = "cfgv" },
{ name = "charset-normalizer" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
{ name = "distlib" },
{ name = "docutils" },
{ name = "execnet" },
{ name = "filelock" },
{ name = "h11" },
{ name = "httpcore" },
{ name = "httpx" },
{ name = "id" },
{ name = "identify" },
{ name = "idna" },
{ name = "iniconfig" },
{ name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
{ name = "jinja2" },
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "linkify-it-py" },
{ name = "macholib", marker = "sys_platform == 'darwin'" }, { name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "markdown-it-py" },
{ name = "markupsafe" },
{ name = "mdit-py-plugins" },
{ name = "mdurl" },
{ name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "nh3" },
{ name = "nodeenv" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" }, { name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "platformdirs" },
{ name = "pluggy" },
{ name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
{ name = "pygments" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pyinstaller-hooks-contrib" }, { name = "pyinstaller-hooks-contrib" },
{ name = "pyinstrument" },
{ name = "python-discovery" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "pyyaml" },
{ name = "readme-renderer" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "rfc3986" },
{ name = "rich" },
{ name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
{ name = "setuptools" }, { name = "setuptools" },
{ name = "syrupy" },
{ name = "textual" },
{ name = "truststore" }, { name = "truststore" },
{ name = "typing-extensions" },
{ name = "uc-micro-py" },
{ name = "urllib3" },
{ name = "virtualenv" },
] ]
dev = [ dev = [
{ name = "debugpy" }, { name = "debugpy" },
@ -988,7 +1040,7 @@ requires-dist = [
{ name = "linkify-it-py", specifier = "==2.1.0" }, { name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "markdown-it-py", specifier = "==4.0.0" }, { name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "markdownify", specifier = "==1.2.2" }, { name = "markdownify", specifier = "==1.2.2" },
{ name = "mcp", specifier = "==1.27.0" }, { name = "mcp", specifier = "==1.27.1" },
{ name = "mdit-py-plugins", specifier = "==0.5.0" }, { name = "mdit-py-plugins", specifier = "==0.5.0" },
{ name = "mdurl", specifier = "==0.1.2" }, { name = "mdurl", specifier = "==0.1.2" },
{ name = "mistralai", specifier = "==2.4.4" }, { name = "mistralai", specifier = "==2.4.4" },
@ -1035,6 +1087,7 @@ requires-dist = [
{ name = "tomli-w", specifier = "==1.2.0" }, { name = "tomli-w", specifier = "==1.2.0" },
{ name = "tree-sitter", specifier = "==0.25.2" }, { name = "tree-sitter", specifier = "==0.25.2" },
{ name = "tree-sitter-bash", specifier = "==0.25.1" }, { name = "tree-sitter-bash", specifier = "==0.25.1" },
{ name = "truststore", specifier = "==0.10.4" },
{ name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-extensions", specifier = "==4.15.0" },
{ name = "typing-inspection", specifier = "==0.4.2" }, { name = "typing-inspection", specifier = "==0.4.2" },
{ name = "uc-micro-py", specifier = "==2.0.0" }, { name = "uc-micro-py", specifier = "==2.0.0" },
@ -1049,14 +1102,65 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
build = [ build = [
{ name = "altgraph", specifier = "==0.17.5" }, { name = "altgraph", specifier = "==0.17.5" },
{ name = "anyio", specifier = "==4.13.0" },
{ name = "certifi", specifier = "==2026.4.22" },
{ name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==2.0.0" },
{ name = "cfgv", specifier = "==3.5.0" },
{ name = "charset-normalizer", specifier = "==3.4.7" },
{ name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" },
{ name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==47.0.0" },
{ name = "distlib", specifier = "==0.4.0" },
{ name = "docutils", specifier = "==0.22.4" },
{ name = "execnet", specifier = "==2.1.2" },
{ name = "filelock", specifier = "==3.29.0" },
{ name = "h11", specifier = "==0.16.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "id", specifier = "==1.6.1" },
{ name = "identify", specifier = "==2.6.19" },
{ name = "idna", specifier = "==3.13" },
{ name = "iniconfig", specifier = "==2.3.0" },
{ name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==3.4.0" },
{ name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==6.1.2" },
{ name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==4.4.0" },
{ name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==0.9.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==25.7.0" },
{ name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" }, { name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" },
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ name = "markupsafe", specifier = "==3.0.3" },
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==11.0.2" },
{ name = "nh3", specifier = "==0.3.5" },
{ name = "nodeenv", specifier = "==1.10.0" },
{ name = "packaging", specifier = "==26.2" }, { name = "packaging", specifier = "==26.2" },
{ name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" }, { name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" },
{ name = "platformdirs", specifier = "==4.9.6" },
{ name = "pluggy", specifier = "==1.6.0" },
{ name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==3.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyinstaller", specifier = "==6.20.0" }, { name = "pyinstaller", specifier = "==6.20.0" },
{ name = "pyinstaller-hooks-contrib", specifier = "==2026.4" }, { name = "pyinstaller-hooks-contrib", specifier = "==2026.4" },
{ name = "pyinstrument", specifier = "==5.1.2" },
{ name = "python-discovery", specifier = "==1.2.2" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" },
{ name = "pyyaml", specifier = "==6.0.3" },
{ name = "readme-renderer", specifier = "==44.0" },
{ name = "requests", specifier = "==2.33.1" },
{ name = "requests-toolbelt", specifier = "==1.0.0" },
{ name = "rfc3986", specifier = "==2.0.0" },
{ name = "rich", specifier = "==15.0.0" },
{ name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==3.5.0" },
{ name = "setuptools", specifier = "==82.0.1" }, { name = "setuptools", specifier = "==82.0.1" },
{ name = "syrupy", specifier = "==4.8.0" },
{ name = "textual", specifier = "==8.2.7" },
{ name = "truststore", specifier = "==0.10.4" }, { name = "truststore", specifier = "==0.10.4" },
{ name = "typing-extensions", specifier = "==4.15.0" },
{ name = "uc-micro-py", specifier = "==2.0.0" },
{ name = "urllib3", specifier = "==2.6.3" },
{ name = "virtualenv", specifier = "==21.2.4" },
] ]
dev = [ dev = [
{ name = "debugpy", specifier = ">=1.8.19" }, { name = "debugpy", specifier = ">=1.8.19" },

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent 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 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]: def _handle_auth_status(self) -> dict[str, Any]:
_, auth_state = self._assess_current_auth_state() _, auth_state = self._assess_current_auth_state()
return _auth_status_response_from_auth_state(auth_state).model_dump( return _auth_status_response_from_auth_state(auth_state).model_dump(
@ -1531,6 +1539,14 @@ class VibeAcpAgentLoop(AcpAgent):
try: try:
self._remove_api_key(provider) 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: except (OSError, ValueError) as exc:
raise InternalError(f"Failed to sign out: {exc}") from 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()) await session.agent_loop.compact(extra_instructions=cmd_args.strip())
new_tokens = session.agent_loop.stats.context_tokens
end_event = CompactEndEvent( end_event = CompactEndEvent(
old_context_tokens=old_tokens or 0,
new_context_tokens=new_tokens or 0,
summary_length=0, summary_length=0,
old_session_id=old_session_id, old_session_id=old_session_id,
new_session_id=session.agent_loop.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.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage 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: if TYPE_CHECKING:
from vibe.core.config import ModelConfig from vibe.core.config import ModelConfig
@ -230,9 +230,7 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
content=TextContentBlock( content=TextContentBlock(
type="text", type="text",
text=( text=(
compact_reduction_display( compact_complete_display(
event.old_context_tokens,
event.new_context_tokens,
old_session_id=event.old_session_id, old_session_id=event.old_session_id,
new_session_id=event.new_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, WhoAmIResponse,
) )
from vibe.core.config import ( from vibe.core.config import (
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY, DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_VIBE_BASE_URL,
ProviderConfig, ProviderConfig,
) )
from vibe.core.types import Backend from vibe.core.types import Backend
@ -97,20 +97,18 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
def plan_offer_cta( 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: ) -> str | None:
if not payload: if not payload:
return return
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli" vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
switch_to_pro_key_url = console_cli_url
upgrade_url = console_cli_url
if payload.prompt_switching_to_pro_plan: 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 ( if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED} payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan() 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 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: if not self.event_handler:
return return
old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage() compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg) await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task( 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( async def _run_compact(
self, self,
compact_msg: CompactMessage, compact_msg: CompactMessage,
old_tokens: int,
old_session_id: str, old_session_id: str,
extra_instructions: str = "", extra_instructions: str = "",
) -> None: ) -> None:
self._agent_running = True self._agent_running = True
try: try:
await self.agent_loop.compact(extra_instructions=extra_instructions) await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete( compact_msg.set_complete(
old_tokens=old_tokens, old_session_id=old_session_id, new_session_id=self.agent_loop.session_id
new_tokens=new_tokens,
old_session_id=old_session_id,
new_session_id=self.agent_loop.session_id,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
@ -2887,13 +2881,18 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container: if self._chat_input_container:
self._chat_input_container.switching_mode = True self._chat_input_container.switching_mode = True
loop = asyncio.get_running_loop()
def schedule_switch() -> None: def schedule_switch() -> None:
self._switch_agent_generation += 1 self._switch_agent_generation += 1
my_gen = self._switch_agent_generation my_gen = self._switch_agent_generation
def switch_agent_sync() -> None: def switch_agent_sync() -> None:
try: 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_approval_callback(self._approval_callback)
self.agent_loop.set_user_input_callback(self._user_input_callback) self.agent_loop.set_user_input_callback(self._user_input_callback)
finally: finally:
@ -3031,7 +3030,7 @@ class VibeApp(App): # noqa: PLR0904
if content is not None: if content is not None:
body = content body = content
plan_offer = plan_offer_cta( 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: if plan_offer is not None:
body = f"{body}\n\n{plan_offer}" body = f"{body}\n\n{plan_offer}"
@ -3059,12 +3058,10 @@ class VibeApp(App): # noqa: PLR0904
if not self._show_vscode_extension_promo: if not self._show_vscode_extension_promo:
return return
promo_message = VscodeExtensionPromoMessage() 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") messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_anchor = chat.is_at_bottom 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: if should_anchor:
chat.anchor() chat.anchor()
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False) self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ from vibe.core.config._settings import (
DEFAULT_TRANSCRIBE_PROVIDERS, DEFAULT_TRANSCRIBE_PROVIDERS,
DEFAULT_TTS_MODELS, DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS, DEFAULT_TTS_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
THINKING_LEVELS, THINKING_LEVELS,
ConnectorConfig, ConnectorConfig,
ExperimentsConfig, ExperimentsConfig,
@ -76,6 +77,7 @@ __all__ = [
"DEFAULT_TRANSCRIBE_PROVIDERS", "DEFAULT_TRANSCRIBE_PROVIDERS",
"DEFAULT_TTS_MODELS", "DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS", "DEFAULT_TTS_PROVIDERS",
"DEFAULT_VIBE_BASE_URL",
"THINKING_LEVELS", "THINKING_LEVELS",
"AppendToList", "AppendToList",
"ConfigDefinitionError", "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.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.types import Backend 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: 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_BASE_URL = "https://console.mistral.ai"
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api" DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai" DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai"
class ProviderConfig(BaseModel): class ProviderConfig(BaseModel):
@ -520,6 +521,7 @@ class VibeConfig(BaseSettings):
enable_update_checks: bool = True enable_update_checks: bool = True
enable_auto_update: bool = True enable_auto_update: bool = True
enable_notifications: bool = True enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = 720.0 api_timeout: float = 720.0
auto_compact_threshold: int = 200_000 auto_compact_threshold: int = 200_000
@ -537,6 +539,7 @@ class VibeConfig(BaseSettings):
otel_endpoint: str = Field(default="", exclude=True) otel_endpoint: str = Field(default="", exclude=True)
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, 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) enable_experimental_hooks: bool = Field(default=False, exclude=True)
@ -1064,7 +1067,11 @@ class VibeConfig(BaseSettings):
@classmethod @classmethod
def load(cls, **overrides: Any) -> VibeConfig: def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate() 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 @classmethod
def create_default(cls) -> dict[str, Any]: def create_default(cls) -> dict[str, Any]:

View file

@ -324,12 +324,6 @@ class AnthropicAdapter(APIAdapter):
"prompt-caching-2024-07-31," "prompt-caching-2024-07-31,"
"context-1m-2025-08-07" "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_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192 DEFAULT_MAX_TOKENS = 8192
@ -375,66 +369,32 @@ class AnthropicAdapter(APIAdapter):
if last_block.get("type") in {"text", "image", "tool_result"}: if last_block.get("type") in {"text", "image", "tool_result"}:
last_block["cache_control"] = {"type": "ephemeral"} 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( def _apply_thinking_config(
self, self,
payload: dict[str, Any], payload: dict[str, Any],
*, *,
model_name: str,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
temperature: float,
max_tokens: int | None, max_tokens: int | None,
thinking: str, thinking: str,
) -> None: ) -> None:
has_thinking = self._has_thinking_content(messages) has_thinking = self._has_thinking_content(messages)
thinking_level = thinking thinking_level = thinking
temperature_deprecated = self._is_temperature_deprecated_model(model_name)
if thinking_level == "off" and not has_thinking: if thinking_level == "off" and not has_thinking:
if not temperature_deprecated: payload["max_tokens"] = (
payload["temperature"] = temperature max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS
if max_tokens is not None: )
payload["max_tokens"] = max_tokens
else:
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
return return
# Resolve effective level: use config, or fallback to "medium" when # Resolve effective level: use config, or fallback to "medium" when
# forced by thinking content in history # forced by thinking content in history
effective_level = thinking_level if thinking_level != "off" else "medium" effective_level = thinking_level if thinking_level != "off" else "medium"
if self._is_adaptive_model(model_name):
payload["thinking"] = {"type": "adaptive", "display": "summarized"} payload["thinking"] = {"type": "adaptive", "display": "summarized"}
payload["output_config"] = {"effort": effective_level} payload["output_config"] = {"effort": effective_level}
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS payload["max_tokens"] = (
else: max_tokens if max_tokens is not None else self.DEFAULT_ADAPTIVE_MAX_TOKENS
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
def _build_payload( def _build_payload(
self, self,
@ -442,7 +402,6 @@ class AnthropicAdapter(APIAdapter):
model_name: str, model_name: str,
system_prompt: str | None, system_prompt: str | None,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
temperature: float,
tools: list[dict[str, Any]] | None, tools: list[dict[str, Any]] | None,
max_tokens: int | None, max_tokens: int | None,
tool_choice: dict[str, Any] | None, tool_choice: dict[str, Any] | None,
@ -452,12 +411,7 @@ class AnthropicAdapter(APIAdapter):
payload: dict[str, Any] = {"model": model_name, "messages": messages} payload: dict[str, Any] = {"model": model_name, "messages": messages}
self._apply_thinking_config( self._apply_thinking_config(
payload, payload, messages=messages, max_tokens=max_tokens, thinking=thinking
model_name=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
) )
if system_blocks := self._build_system_blocks(system_prompt): if system_blocks := self._build_system_blocks(system_prompt):
@ -498,7 +452,6 @@ class AnthropicAdapter(APIAdapter):
model_name=model_name, model_name=model_name,
system_prompt=system_prompt, system_prompt=system_prompt,
messages=converted_messages, messages=converted_messages,
temperature=temperature,
tools=converted_tools, tools=converted_tools,
max_tokens=max_tokens, max_tokens=max_tokens,
tool_choice=converted_tool_choice, tool_choice=converted_tool_choice,

View file

@ -416,35 +416,6 @@ class GenericBackend:
return return
yield json.loads(value.strip()) 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: async def close(self) -> None:
if self._owns_client and self._client: if self._owns_client and self._client:
await self._client.aclose() await self._client.aclose()

View file

@ -426,29 +426,3 @@ class MistralBackend:
has_tools=bool(tools), has_tools=bool(tools),
tool_choice=tool_choice, tool_choice=tool_choice,
) from e ) 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( self._apply_thinking_config(
payload, payload,
model_name=model_name,
messages=converted_messages, messages=converted_messages,
temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
thinking=thinking, thinking=thinking,
) )

View file

@ -14,7 +14,7 @@ class BackendLike(Protocol):
"""Port protocol for dependency-injectable LLM backends. """Port protocol for dependency-injectable LLM backends.
Any backend used by AgentLoop should implement this async context manager 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: ... async def __aenter__(self) -> BackendLike: ...
@ -92,35 +92,3 @@ class BackendLike(Protocol):
BackendError: If the API request fails 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_update_checks = true
enable_auto_update = true enable_auto_update = true
enable_notifications = 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 api_timeout = 720.0 # API request timeout in seconds
auto_compact_threshold = 200000 # Token count before auto-compaction 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" 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 (Experimental)
Hooks let users run shell commands automatically at specific points during a Hooks let users run shell commands automatically at specific points during a

View file

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

View file

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

View file

@ -9,11 +9,12 @@ from pathlib import Path
import threading import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO from typing import TYPE_CHECKING, Any, ClassVar, TextIO
import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client 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.logger import logger
from vibe.core.tools.base import ( from vibe.core.tools.base import (
BaseTool, BaseTool,
@ -25,11 +26,18 @@ from vibe.core.tools.base import (
from vibe.core.tools.mcp_sampling import MCPSamplingHandler from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent from vibe.core.types import ToolStreamEvent
from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING: if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent 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: def _stderr_logger_thread(read_fd: int) -> None:
with open(read_fd, "rb") as f: with open(read_fd, "rb") as f:
for line in iter(f.readline, b""): 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) 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( async def list_tools_http(
url: str, url: str,
*, *,
@ -174,8 +191,15 @@ async def list_tools_http(
startup_timeout_sec: float | None = None, startup_timeout_sec: float | None = None,
) -> list[RemoteTool]: ) -> list[RemoteTool]:
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with streamablehttp_client(url, headers=headers) as (read, write, _): async with create_vibe_mcp_http_client(headers) as http_client:
async with ClientSession(read, write, read_timeout_seconds=timeout) as session: 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() await session.initialize()
tools_resp = await session.list_tools() tools_resp = await session.list_tools()
return [RemoteTool.model_validate(t) for t in tools_resp.tools] return [RemoteTool.model_validate(t) for t in tools_resp.tools]
@ -195,7 +219,12 @@ async def call_tool_http(
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
) )
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_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 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( async with ClientSession(
read, read,
write, write,

View file

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

View file

@ -12,9 +12,10 @@ from vibe.core.utils.concurrency import (
ConversationLimitException, ConversationLimitException,
run_sync, 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 ( from vibe.core.utils.http import (
build_ssl_context, build_ssl_context,
configure_ssl_context,
get_server_url_from_api_base, get_server_url_from_api_base,
get_user_agent, get_user_agent,
) )
@ -55,7 +56,8 @@ __all__ = [
"async_generator_retry", "async_generator_retry",
"async_retry", "async_retry",
"build_ssl_context", "build_ssl_context",
"compact_reduction_display", "compact_complete_display",
"configure_ssl_context",
"get_platform_display_name", "get_platform_display_name",
"get_platform_id", "get_platform_id",
"get_server_url_from_api_base", "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 from vibe.core.session.session_id import shorten_session_id
def compact_reduction_display( def compact_complete_display(
old_tokens: int | None, old_session_id: str | None = None, new_session_id: str | None = None
new_tokens: int | None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> str: ) -> str:
message = "Compaction complete" message = "Compaction completed."
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}%)"
)
if old_session_id is not None and new_session_id is not None: if old_session_id is not None and new_session_id is not None:
short_old = shorten_session_id(old_session_id) short_old = shorten_session_id(old_session_id)
short_new = shorten_session_id(new_session_id) short_new = shorten_session_id(new_session_id)

View file

@ -6,13 +6,27 @@ import re
import ssl import ssl
import certifi import certifi
import truststore
from vibe import __version__ from vibe import __version__
from vibe.core.types import Backend 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) @functools.lru_cache(maxsize=1)
def build_ssl_context() -> ssl.SSLContext: def build_ssl_context() -> ssl.SSLContext:
if _use_system_trust_store:
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
else:
ctx = ssl.create_default_context(cafile=certifi.where()) ctx = ssl.create_default_context(cafile=certifi.where())
# Custom certs are additive so private-CA users don't lose public roots. # Custom certs are additive so private-CA users don't lose public roots.

View file

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

View file

@ -42,6 +42,7 @@ class OnboardingApp(App[str | None]):
self._config = config self._config = config
self._provider = config.provider self._provider = config.provider
self._vibe_base_url = config.vibe_base_url
self._entrypoint_metadata = entrypoint_metadata self._entrypoint_metadata = entrypoint_metadata
self._browser_sign_in_success_delay = browser_sign_in_success_delay self._browser_sign_in_success_delay = browser_sign_in_success_delay
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory( 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" ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
) )
self.install_screen( 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", "api_key",
) )
if self._browser_sign_in_service_factory is not None: 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_ACTIVE_MODEL,
DEFAULT_MODELS, DEFAULT_MODELS,
DEFAULT_PROVIDERS, DEFAULT_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
) )
from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger from vibe.core.logger import logger
@ -29,6 +30,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
class _OnboardingSnapshot(BaseModel): class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL active_model: str = DEFAULT_ACTIVE_MODEL
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
providers: list[Any] = Field(default_factory=_default_provider_payloads) providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_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 and (models := _find_env_value("VIBE_MODELS")) is not None
): ):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models) 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 return payload
@ -180,6 +188,7 @@ def _resolve_provider(
@dataclass(frozen=True) @dataclass(frozen=True)
class OnboardingContext: class OnboardingContext:
provider: ProviderConfig provider: ProviderConfig
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
@property @property
def supports_browser_sign_in(self) -> bool: def supports_browser_sign_in(self) -> bool:
@ -187,7 +196,9 @@ class OnboardingContext:
@classmethod @classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext: 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 @classmethod
def load(cls, **overrides: Any) -> OnboardingContext: def load(cls, **overrides: Any) -> OnboardingContext:
@ -198,7 +209,8 @@ class OnboardingContext:
return cls( return cls(
provider=_resolve_provider( provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot active_model=snapshot.active_model, snapshot=snapshot
) ),
vibe_base_url=snapshot.vibe_base_url,
) )
except (RuntimeError, ValidationError, ValueError): except (RuntimeError, ValidationError, ValueError):
logger.warning( 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.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic 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.core.telemetry.types import EntrypointMetadata
from vibe.setup.auth.api_key_persistence import ( from vibe.setup.auth.api_key_persistence import (
persist_api_key, persist_api_key,
@ -20,8 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
) )
from vibe.setup.onboarding.base import OnboardingScreen from vibe.setup.onboarding.base import OnboardingScreen
# providers with a dedicated key creation page get a direct onboarding link MISTRAL_PROVIDER_NAME = "mistral"
PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")} MISTRAL_PROVIDER_HELP_NAME = "Mistral Vibe"
CONFIG_DOCS_URL = ( CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration" "https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
) )
@ -39,17 +39,19 @@ class ApiKeyScreen(OnboardingScreen):
self, self,
provider: ProviderConfig | None = None, provider: ProviderConfig | None = None,
*, *,
vibe_base_url: str = DEFAULT_VIBE_BASE_URL,
entrypoint_metadata: EntrypointMetadata | None = None, entrypoint_metadata: EntrypointMetadata | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.provider = resolve_api_key_provider(provider) self.provider = resolve_api_key_provider(provider)
self._vibe_base_url = vibe_base_url
self._entrypoint_metadata = entrypoint_metadata self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self) -> ComposeResult: 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 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") yield Link(help_url, url=help_url, id="api-key-provider-link")
def _compose_config_docs(self) -> ComposeResult: def _compose_config_docs(self) -> ComposeResult:
@ -60,8 +62,11 @@ class ApiKeyScreen(OnboardingScreen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
provider_name = self.provider.name.capitalize() provider_name = self.provider.name.capitalize()
help_info = PROVIDER_HELP.get(self.provider.name) help_name = (
help_name = help_info[1] if help_info is not None else "your provider" MISTRAL_PROVIDER_HELP_NAME
if self.provider.name == MISTRAL_PROVIDER_NAME
else "your provider"
)
self.input_widget = Input( self.input_widget = Input(
password=True, 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/`. - **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. - **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.