diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
index 5bc81a2..ebace8f 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.yml
+++ b/.github/ISSUE_TEMPLATE/bug-report.yml
@@ -17,7 +17,6 @@ body:
options:
- CLI
- ACP
- - Both
- VS Code extension
- Other
validations:
diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml
index 0bc4b0e..b02b524 100644
--- a/.github/workflows/issue-labeler.yml
+++ b/.github/workflows/issue-labeler.yml
@@ -47,8 +47,8 @@ jobs:
labels.push('CLI');
} else if (component === 'ACP') {
labels.push('ACP');
- } else if (component === 'Both') {
- labels.push('CLI', 'ACP');
+ } else if (component === 'VS Code extension') {
+ labels.push('VS Code extension');
}
if (labels.length > 0) {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 611d2ea..3a1f564 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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/),
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
### Fixed
diff --git a/README.md b/README.md
index cda97da..eb4be39 100644
--- a/README.md
+++ b/README.md
@@ -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`.
+### 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
You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt.
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index 117ebe5..c1437a1 100644
--- a/distribution/zed/extension.toml
+++ b/distribution/zed/extension.toml
@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
-version = "2.12.1"
+version = "2.13.0"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[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"
[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"
[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"
[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"
[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"
diff --git a/pyproject.toml b/pyproject.toml
index d1877ee..eb8b4ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.12.1"
+version = "2.13.0"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
@@ -64,7 +64,7 @@ dependencies = [
"linkify-it-py==2.1.0",
"markdown-it-py==4.0.0",
"markdownify==1.2.2",
- "mcp==1.27.0",
+ "mcp==1.27.1",
"mdit-py-plugins==0.5.0",
"mdurl==0.1.2",
"mistralai==2.4.4",
@@ -111,6 +111,7 @@ dependencies = [
"tomli-w==1.2.0",
"tree-sitter==0.25.2",
"tree-sitter-bash==0.25.1",
+ "truststore==0.10.4",
"typing-extensions==4.15.0",
"typing-inspection==0.4.2",
"uc-micro-py==2.0.0",
@@ -173,14 +174,65 @@ dev = [
build = [
"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'",
+ "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",
"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-hooks-contrib==2026.4",
+ "pyinstrument==5.1.2",
+ "python-discovery==1.2.2",
"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",
+ "syrupy==4.8.0",
+ "textual==8.2.7",
"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]
diff --git a/tests/acp/test_auth_status.py b/tests/acp/test_auth_status.py
index b322a20..b0703c1 100644
--- a/tests/acp/test_auth_status.py
+++ b/tests/acp/test_auth_status.py
@@ -139,7 +139,7 @@ class TestACPAuthStatus:
assert response == {
"authenticated": True,
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
- "signOutAvailable": False,
+ "signOutAvailable": True,
}
@pytest.mark.asyncio
@@ -213,22 +213,23 @@ class TestACPAuthSignOut:
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
@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
) -> None:
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
- with pytest.raises(
- InvalidRequestError,
- match=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
- ):
- await acp_agent_loop.ext_method("auth/signOut", {})
+ response = await acp_agent_loop.ext_method("auth/signOut", {})
- assert dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY] == (
- "file-key"
- )
+ assert response == {}
+ 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
async def test_refuses_unsupported_provider_key(
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index 6fffe5f..17ede35 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
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
@@ -94,7 +94,7 @@ class TestACPInitialize:
),
)
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
diff --git a/tests/backend/test_anthropic_adapter.py b/tests/backend/test_anthropic_adapter.py
index c316d17..68e4906 100644
--- a/tests/backend/test_anthropic_adapter.py
+++ b/tests/backend/test_anthropic_adapter.py
@@ -315,7 +315,7 @@ class TestAdapterPrepareRequest:
payload = json.loads(req.body)
assert payload["model"] == "claude-sonnet-4-20250514"
assert payload["max_tokens"] == 1024
- assert payload["temperature"] == 0.5
+ assert "temperature" not in payload
assert req.endpoint == "/v1/messages"
assert req.headers["anthropic-version"] == "2023-06-01"
@@ -392,9 +392,10 @@ class TestAdapterPrepareRequest:
thinking="medium",
)
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["temperature"] == 1
+ assert "temperature" not in payload
def test_system_cached(self, adapter, provider):
messages = [
@@ -439,12 +440,8 @@ class TestAdapterPrepareRequest:
assert len(payload["tools"]) == 1
assert payload["tools"][0]["name"] == "test_tool"
- @pytest.mark.parametrize(
- "level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)]
- )
- def test_thinking_levels_budget_model(
- self, adapter, provider, level, expected_budget
- ):
+ @pytest.mark.parametrize("level", ["low", "medium", "high", "max"])
+ def test_thinking_levels(self, adapter, provider, level):
messages = [LLMMessage(role=Role.user, content="Hello")]
req = adapter.prepare_request(
model_name="claude-sonnet-4-20250514",
@@ -458,59 +455,12 @@ class TestAdapterPrepareRequest:
thinking=level,
)
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["output_config"] == {"effort": level}
- if "opus-4-7" in model_name:
- assert "temperature" not in payload
- else:
- assert payload["temperature"] == 1
+ assert "temperature" not in payload
assert payload["max_tokens"] == 32_768
- @pytest.mark.parametrize("thinking_level", ["off", "low", "medium", "high"])
- 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):
+ def test_history_forced_thinking(self, adapter, provider):
messages = [
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(
@@ -532,34 +482,9 @@ class TestAdapterPrepareRequest:
provider=provider,
)
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["output_config"] == {"effort": "medium"}
+ assert "temperature" not in payload
assert payload["max_tokens"] == 32_768
diff --git a/tests/backend/test_ssl_integration.py b/tests/backend/test_ssl_integration.py
new file mode 100644
index 0000000..045c7b1
--- /dev/null
+++ b/tests/backend/test_ssl_integration.py
@@ -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)
+ )
diff --git a/tests/backend/test_vertex_anthropic_adapter.py b/tests/backend/test_vertex_anthropic_adapter.py
index 77b905c..c229e43 100644
--- a/tests/backend/test_vertex_anthropic_adapter.py
+++ b/tests/backend/test_vertex_anthropic_adapter.py
@@ -91,7 +91,7 @@ class TestPrepareRequest:
assert payload["anthropic_version"] == "vertex-2023-10-16"
assert "model" not in payload
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["anthropic-beta"] == adapter.BETA_FEATURES
assert "rawPredict" in req.endpoint
@@ -147,9 +147,10 @@ class TestPrepareRequest:
)
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["temperature"] == 1
+ assert "temperature" not in payload
def test_with_tools(self, adapter, provider):
messages = [LLMMessage(role=Role.user, content="Hello")]
diff --git a/tests/cli/plan_offer/test_decide_plan_offer.py b/tests/cli/plan_offer/test_decide_plan_offer.py
index 95a1d40..66bdb54 100644
--- a/tests/cli/plan_offer/test_decide_plan_offer.py
+++ b/tests/cli/plan_offer/test_decide_plan_offer.py
@@ -11,6 +11,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
WhoAmIPlanType,
decide_plan_offer,
+ plan_offer_cta,
resolve_api_key_for_plan,
)
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
+@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(
("response", "expected"),
[
diff --git a/tests/cli/test_compact_message.py b/tests/cli/test_compact_message.py
index 0183f0a..1cb802b 100644
--- a/tests/cli/test_compact_message.py
+++ b/tests/cli/test_compact_message.py
@@ -12,14 +12,12 @@ class TestCompactMessage:
message.post_message = MagicMock()
message.set_complete(
- old_tokens=177_017,
- new_tokens=23_263,
old_session_id="11111111-1111-1111-1111-111111111111",
new_session_id="22222222-2222-2222-2222-222222222222",
)
assert message.get_content() == (
- "Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
+ "Compaction completed.\n"
"session: "
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
"(before compaction) → "
diff --git a/tests/core/test_build_ssl_context.py b/tests/core/test_build_ssl_context.py
index 95b04cc..0b79a4b 100644
--- a/tests/core/test_build_ssl_context.py
+++ b/tests/core/test_build_ssl_context.py
@@ -5,13 +5,15 @@ from unittest.mock import MagicMock, patch
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)
def _clear_ssl_cache():
+ configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
yield
+ configure_ssl_context(enable_system_trust_store=False)
build_ssl_context.cache_clear()
@@ -20,6 +22,42 @@ def test_build_ssl_context_returns_ssl_context():
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):
cert_file = tmp_path / "custom.pem"
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):
cert_dir = tmp_path / "certs"
cert_dir.mkdir()
diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py
index 6f1279a..50b534d 100644
--- a/tests/core/test_config_resolution.py
+++ b/tests/core/test_config_resolution.py
@@ -2,8 +2,10 @@ from __future__ import annotations
import json
from pathlib import Path
+import ssl
import tomllib
from typing import Literal, TypedDict, Unpack
+from unittest.mock import MagicMock, patch
import pytest
import tomli_w
@@ -23,6 +25,7 @@ from vibe.core.config.harness_files import (
from vibe.core.paths import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
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
@@ -198,6 +201,73 @@ class TestSaveUpdates:
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:
def test_persists_thinking_to_toml(self, config_dir: Path) -> None:
config_file = config_dir / "config.toml"
@@ -821,6 +891,7 @@ class TestOnboardingContextResolution:
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
reset_harness_files_manager()
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
+ monkeypatch.setenv("VIBE_VIBE_BASE_URL", "https://env-vibe.example.com")
monkeypatch.setenv(
"VIBE_PROVIDERS",
json.dumps([
@@ -842,6 +913,7 @@ class TestOnboardingContextResolution:
assert context.provider.name == "env-provider"
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(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py
index 580f0bd..4141d5d 100644
--- a/tests/core/test_telemetry_send.py
+++ b/tests/core/test_telemetry_send.py
@@ -331,17 +331,13 @@ class TestTelemetryClient:
client = TelemetryClient(config_getter=lambda: config)
client.send_auto_compact_triggered(
- nb_context_tokens_before=123,
- nb_context_tokens_after=45,
- auto_compact_threshold=100,
- status="success",
+ nb_context_tokens_before=123, auto_compact_threshold=100, status="success"
)
assert len(telemetry_events) == 1
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
assert telemetry_events[0]["properties"] == {
"nb_context_tokens_before": 123,
- "nb_context_tokens_after": 45,
"auto_compact_threshold": 100,
"status": "success",
}
@@ -674,7 +670,6 @@ class TestTelemetryClient:
client.send_auto_compact_triggered(
nb_context_tokens_before=123,
- nb_context_tokens_after=45,
auto_compact_threshold=100,
status="success",
session_id="original-session-id",
diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py
index 7fcc01a..450006c 100644
--- a/tests/core/test_utils.py
+++ b/tests/core/test_utils.py
@@ -4,7 +4,7 @@ from pathlib import Path
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
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
-class TestCompactReductionDisplay:
+class TestCompactCompleteDisplay:
def test_includes_session_ids_when_available(self) -> None:
- assert compact_reduction_display(
- 177_017,
- 23_263,
+ assert compact_complete_display(
old_session_id="11111111-1111-1111-1111-111111111111",
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)"
)
def test_returns_base_message_without_session_ids(self) -> None:
- assert compact_reduction_display(177_017, 23_263) == (
- "Compaction complete: 177,017 → 23,263 tokens (-87.%)"
- )
+ assert compact_complete_display() == "Compaction completed."
class TestReadSafe:
diff --git a/tests/e2e/mock_server.py b/tests/e2e/mock_server.py
index fea88e1..c69a4c8 100644
--- a/tests/e2e/mock_server.py
+++ b/tests/e2e/mock_server.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
+import ssl
import threading
import time
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._lock = threading.Lock()
self._chunk_factory = chunk_factory
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)
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
@@ -138,7 +149,7 @@ class StreamingMockServer:
@property
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:
self._thread.start()
diff --git a/tests/onboarding/test_ui_onboarding.py b/tests/onboarding/test_ui_onboarding.py
index 8333368..c32f5ae 100644
--- a/tests/onboarding/test_ui_onboarding.py
+++ b/tests/onboarding/test_ui_onboarding.py
@@ -12,7 +12,7 @@ from textual.geometry import Size
from textual.pilot import Pilot
from textual.screen import Screen
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.conftest import build_test_vibe_config
@@ -70,6 +70,7 @@ def _build_onboarding_config(
api_key_env_var: str = "MISTRAL_API_KEY",
browser_auth_base_url: str | None = None,
browser_auth_api_base_url: str | None = None,
+ vibe_base_url: str = "https://chat.mistral.ai",
) -> VibeConfig:
provider = ProviderConfig(
name=provider_name,
@@ -84,7 +85,9 @@ def _build_onboarding_config(
provider=model_provider or provider_name,
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(
@@ -236,6 +239,12 @@ async def _show_browser_sign_in(pilot: Pilot) -> None:
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
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
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"
+@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:
provider = ProviderConfig(
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
diff --git a/tests/setup/auth/test_auth_state.py b/tests/setup/auth/test_auth_state.py
index 5d28797..2b6b1b6 100644
--- a/tests/setup/auth/test_auth_state.py
+++ b/tests/setup/auth/test_auth_state.py
@@ -116,7 +116,7 @@ def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist
assert state == AuthState(
kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True,
- sign_out_available=False,
+ sign_out_available=True,
env_key=DEFAULT_MISTRAL_API_ENV_KEY,
)
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg
index 8dd73b0..17e78cb 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_onboarding_api_key/test_snapshot_onboarding_api_key_with_valid_input.svg
@@ -129,13 +129,13 @@
- ⢠⢢ ⡔⢄⠔⡄
- ⢸⢸⣀⡔⢉⠱⣃⡐⣔⡣
- ⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆
+ ⡠⣒⠄ ⡔⢄⠔⡄
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆
Get your Mistral API key
-Visit AI Studio to generate or copy your Vibe key
+Visit Mistral Vibe to generate or copy your Vibe key
-https://console.mistral.ai/codestral/cli
+https://chat.mistral.ai/code/extensions?focus=key
┌─ Paste API key ────────────────────────────────────────────────────────┐
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg
index fdd97eb..4a6b70c 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg
@@ -189,7 +189,7 @@
┃• Feature 2
┃• Feature 3
┃
-┃Switch to your Le Chat Pro API key
+┃Switch to your Vibe Pro API key
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg
index c956d8a..73490ac 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg
@@ -189,7 +189,7 @@
┃• Feature 2
┃• Feature 3
┃
-┃Unlock more with Vibe - Upgrade to Le Chat Pro
+┃Unlock more with Vibe - Upgrade to Vibe Pro
diff --git a/tests/snapshots/test_ui_snapshot_onboarding_api_key.py b/tests/snapshots/test_ui_snapshot_onboarding_api_key.py
index 47ea91f..4cbab47 100644
--- a/tests/snapshots/test_ui_snapshot_onboarding_api_key.py
+++ b/tests/snapshots/test_ui_snapshot_onboarding_api_key.py
@@ -1,14 +1,23 @@
from __future__ import annotations
+from typing import Any
+
+import pytest
from textual.app import App
from textual.pilot import Pilot
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.types import Backend
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]):
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(
- snap_compare: SnapCompare,
+ snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
) -> None:
+ monkeypatch.setattr(
+ "vibe.setup.onboarding.screens.api_key.PetitChat", StaticPetitChat
+ )
+
async def run_before(pilot: Pilot) -> None:
await pilot.pause(0.2)
await pilot.press(*"sk-test-api-key")
diff --git a/tests/stubs/fake_backend.py b/tests/stubs/fake_backend.py
index 4fd042d..e7e46c5 100644
--- a/tests/stubs/fake_backend.py
+++ b/tests/stubs/fake_backend.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
+from collections.abc import AsyncGenerator, Iterable
from typing import cast
from tests.mock.utils import mock_llm_chunk
@@ -21,7 +21,6 @@ class FakeBackend:
| Iterable[Iterable[LLMChunk]]
| None = None,
*,
- token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
exception_to_raise: Exception | None = None,
) -> None:
"""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_extra_headers: 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._streams: list[list[LLMChunk]]
@@ -70,10 +67,6 @@ class FakeBackend:
def requests_metadata(self) -> list[dict[str, str] | None]:
return self._requests_metadata
- @staticmethod
- def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
- return 1
-
async def __aenter__(self):
return self
@@ -133,20 +126,3 @@ class FakeBackend:
stream = [mock_llm_chunk(content="")]
for chunk in stream:
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)
diff --git a/tests/test_agent_auto_compact.py b/tests/test_agent_auto_compact.py
index 94d4ec0..9dd4458 100644
--- a/tests/test_agent_auto_compact.py
+++ b/tests/test_agent_auto_compact.py
@@ -60,13 +60,11 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
- assert end.old_context_tokens == 2
- assert end.new_context_tokens >= 1
+ assert isinstance(end, CompactEndEvent)
assert final.content == ""
properties = _get_auto_compact_properties(telemetry_events)
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["status"] == "success"
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)
assert properties["nb_context_tokens_before"] == 2
- assert properties["nb_context_tokens_after"] == 2
assert properties["auto_compact_threshold"] == 1
assert properties["status"] == expected_status
assert properties["session_id"] == old_session_id
diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py
index faf0bc4..a1ac706 100644
--- a/tests/test_agent_backend.py
+++ b/tests/test_agent_backend.py
@@ -335,8 +335,8 @@ async def test_mistral_metadata_header_call_type_per_turn() -> None:
@pytest.mark.asyncio
-async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> None:
- """Compact emits summary, token-count, then user-turn backend metadata in order."""
+async def test_auto_compact_emits_summary_and_next_turn_metadata() -> None:
+ """Compact emits summary then user-turn backend metadata in order."""
backend = FakeBackend([
[mock_llm_chunk(content="")],
[mock_llm_chunk(content="")],
@@ -358,32 +358,24 @@ async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> No
[_ async for _ in agent.act("Hello")]
- assert len(backend.requests_metadata) == 3
- assert len(backend.requests_extra_headers) == 3
+ assert len(backend.requests_metadata) == 2
+ assert len(backend.requests_extra_headers) == 2
compact_metadata = backend.requests_metadata[0]
- recount_metadata = backend.requests_metadata[1]
- user_turn_metadata = backend.requests_metadata[2]
+ user_turn_metadata = backend.requests_metadata[1]
assert compact_metadata is not None
- assert recount_metadata is not None
assert user_turn_metadata is not None
assert compact_metadata["call_type"] == "secondary_call"
assert compact_metadata["session_id"] == original_session_id
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["session_id"] == agent.session_id
assert user_turn_metadata["parent_session_id"] == original_session_id
compact_headers = backend.requests_extra_headers[0]
- recount_headers = backend.requests_extra_headers[1]
- user_turn_headers = backend.requests_extra_headers[2]
+ user_turn_headers = backend.requests_extra_headers[1]
assert compact_headers is not None
- assert recount_headers is not None
assert user_turn_headers is not None
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
diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py
index c0c5afc..81dfc54 100644
--- a/tests/test_agent_stats.py
+++ b/tests/test_agent_stats.py
@@ -526,13 +526,10 @@ class TestAutoCompactIntegration:
assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[1]
- end: CompactEndEvent = events[2]
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
- assert end.old_context_tokens == 2
- assert end.new_context_tokens >= 1
assert final.content == ""
roles = [r for r, _ in observed]
diff --git a/tests/test_deferred_init.py b/tests/test_deferred_init.py
index 46c835c..cc05058 100644
--- a/tests/test_deferred_init.py
+++ b/tests/test_deferred_init.py
@@ -481,6 +481,54 @@ class TestACloseCancelsExperimentsTask:
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:
@pytest.mark.asyncio
async def test_act_awaits_experiments_before_llm_call(self) -> None:
diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py
index 96a27a1..b6f33c2 100644
--- a/tests/tools/test_mcp.py
+++ b/tests/tools/test_mcp.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import contextlib
import logging
import os
import threading
import time
+from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -20,9 +22,12 @@ from vibe.core.tools.mcp import (
_mcp_stderr_capture,
_parse_call_result,
_stderr_logger_thread,
+ call_tool_http,
call_tool_stdio,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
+ create_vibe_mcp_http_client,
+ list_tools_http,
list_tools_stdio,
)
@@ -133,6 +138,130 @@ class TestParseCallResult:
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:
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""
diff --git a/uv.lock b/uv.lock
index fae5117..389526a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
-exclude-newer = "2026-05-20T19:55:04.890389Z"
+exclude-newer = "2026-05-22T14:05:58.077106Z"
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@@ -775,7 +775,7 @@ wheels = [
[[package]]
name = "mcp"
-version = "1.27.0"
+version = "1.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -793,9 +793,9 @@ dependencies = [
{ name = "typing-inspection" },
{ 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 = [
- { 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]]
@@ -821,7 +821,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.12.1"
+version = "2.13.0"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
@@ -908,6 +908,7 @@ dependencies = [
{ name = "tomli-w" },
{ name = "tree-sitter" },
{ name = "tree-sitter-bash" },
+ { name = "truststore" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uc-micro-py" },
@@ -922,14 +923,65 @@ dependencies = [
[package.dev-dependencies]
build = [
{ 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 = "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 = "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-hooks-contrib" },
+ { name = "pyinstrument" },
+ { name = "python-discovery" },
{ 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 = "syrupy" },
+ { name = "textual" },
{ name = "truststore" },
+ { name = "typing-extensions" },
+ { name = "uc-micro-py" },
+ { name = "urllib3" },
+ { name = "virtualenv" },
]
dev = [
{ name = "debugpy" },
@@ -988,7 +1040,7 @@ requires-dist = [
{ name = "linkify-it-py", specifier = "==2.1.0" },
{ name = "markdown-it-py", specifier = "==4.0.0" },
{ 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 = "mdurl", specifier = "==0.1.2" },
{ name = "mistralai", specifier = "==2.4.4" },
@@ -1035,6 +1087,7 @@ requires-dist = [
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "tree-sitter", specifier = "==0.25.2" },
{ name = "tree-sitter-bash", specifier = "==0.25.1" },
+ { name = "truststore", specifier = "==0.10.4" },
{ name = "typing-extensions", specifier = "==4.15.0" },
{ name = "typing-inspection", specifier = "==0.4.2" },
{ name = "uc-micro-py", specifier = "==2.0.0" },
@@ -1049,14 +1102,65 @@ requires-dist = [
[package.metadata.requires-dev]
build = [
{ 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 = "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 = "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-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 = "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 = "syrupy", specifier = "==4.8.0" },
+ { name = "textual", specifier = "==8.2.7" },
{ 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 = [
{ name = "debugpy", specifier = ">=1.8.19" },
diff --git a/vibe/__init__.py b/vibe/__init__.py
index 90da959..8950d2d 100644
--- a/vibe/__init__.py
+++ b/vibe/__init__.py
@@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
-__version__ = "2.12.1"
+__version__ = "2.13.0"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index 8f07fc5..529b00a 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -1516,6 +1516,14 @@ class VibeAcpAgentLoop(AcpAgent):
)
return provider, auth_state
+ def _process_env_value_before_dotenv_load(
+ self, provider: ProviderConfig
+ ) -> str | None:
+ if not provider.api_key_env_var:
+ return None
+
+ return self._environ_before_dotenv_load.get(provider.api_key_env_var)
+
def _handle_auth_status(self) -> dict[str, Any]:
_, auth_state = self._assess_current_auth_state()
return _auth_status_response_from_auth_state(auth_state).model_dump(
@@ -1531,6 +1539,14 @@ class VibeAcpAgentLoop(AcpAgent):
try:
self._remove_api_key(provider)
+ if (
+ auth_state.kind
+ == AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV
+ and auth_state.env_key
+ ):
+ process_env_value = self._process_env_value_before_dotenv_load(provider)
+ if process_env_value:
+ os.environ[auth_state.env_key] = process_env_value
except (OSError, ValueError) as exc:
raise InternalError(f"Failed to sign out: {exc}") from exc
@@ -1650,11 +1666,8 @@ class VibeAcpAgentLoop(AcpAgent):
)
await session.agent_loop.compact(extra_instructions=cmd_args.strip())
- new_tokens = session.agent_loop.stats.context_tokens
end_event = CompactEndEvent(
- old_context_tokens=old_tokens or 0,
- new_context_tokens=new_tokens or 0,
summary_length=0,
old_session_id=old_session_id,
new_session_id=session.agent_loop.session_id,
diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py
index 7f8c3da..36cd74b 100644
--- a/vibe/acp/utils.py
+++ b/vibe/acp/utils.py
@@ -26,7 +26,7 @@ from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
-from vibe.core.utils import compact_reduction_display
+from vibe.core.utils import compact_complete_display
if TYPE_CHECKING:
from vibe.core.config import ModelConfig
@@ -230,9 +230,7 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
content=TextContentBlock(
type="text",
text=(
- compact_reduction_display(
- event.old_context_tokens,
- event.new_context_tokens,
+ compact_complete_display(
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
)
diff --git a/vibe/cli/plan_offer/decide_plan_offer.py b/vibe/cli/plan_offer/decide_plan_offer.py
index c048d30..fc66fed 100644
--- a/vibe/cli/plan_offer/decide_plan_offer.py
+++ b/vibe/cli/plan_offer/decide_plan_offer.py
@@ -12,8 +12,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIResponse,
)
from vibe.core.config import (
- DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
+ DEFAULT_VIBE_BASE_URL,
ProviderConfig,
)
from vibe.core.types import Backend
@@ -97,20 +97,18 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
def plan_offer_cta(
- payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
+ payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
) -> str | None:
if not payload:
return
- console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
- switch_to_pro_key_url = console_cli_url
- upgrade_url = console_cli_url
+ vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
if payload.prompt_switching_to_pro_plan:
- return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
+ return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})"
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
- return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
+ return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})"
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index bb8fc76..1c826b2 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -2183,32 +2183,26 @@ class VibeApp(App): # noqa: PLR0904
if not self.event_handler:
return
- old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
- self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
+ self._run_compact(compact_msg, old_session_id, cmd_args.strip())
)
async def _run_compact(
self,
compact_msg: CompactMessage,
- old_tokens: int,
old_session_id: str,
extra_instructions: str = "",
) -> None:
self._agent_running = True
try:
await self.agent_loop.compact(extra_instructions=extra_instructions)
- new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(
- old_tokens=old_tokens,
- new_tokens=new_tokens,
- old_session_id=old_session_id,
- new_session_id=self.agent_loop.session_id,
+ old_session_id=old_session_id, new_session_id=self.agent_loop.session_id
)
except asyncio.CancelledError:
@@ -2887,13 +2881,18 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.switching_mode = True
+ loop = asyncio.get_running_loop()
+
def schedule_switch() -> None:
self._switch_agent_generation += 1
my_gen = self._switch_agent_generation
def switch_agent_sync() -> None:
try:
- asyncio.run(self.agent_loop.switch_agent(new_profile.name))
+ future = asyncio.run_coroutine_threadsafe(
+ self.agent_loop.switch_agent(new_profile.name), loop
+ )
+ future.result()
self.agent_loop.set_approval_callback(self._approval_callback)
self.agent_loop.set_user_input_callback(self._user_input_callback)
finally:
@@ -3031,7 +3030,7 @@ class VibeApp(App): # noqa: PLR0904
if content is not None:
body = content
plan_offer = plan_offer_cta(
- self._plan_info, console_base_url=self.config.console_base_url
+ self._plan_info, vibe_base_url=self.config.vibe_base_url
)
if plan_offer is not None:
body = f"{body}\n\n{plan_offer}"
@@ -3059,12 +3058,10 @@ class VibeApp(App): # noqa: PLR0904
if not self._show_vscode_extension_promo:
return
promo_message = VscodeExtensionPromoMessage()
- if self._history_widget_indices:
- promo_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_anchor = chat.is_at_bottom
- await chat.mount(promo_message, after=messages_area)
+ await chat.mount(promo_message, before=messages_area)
if should_anchor:
chat.anchor()
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index 8fb134b..c3f92e1 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -1281,10 +1281,6 @@ NarratorStatus {
link-color-hover: $mistral_orange;
}
-.vscode-extension-promo-message.after-history {
- margin-top: 1;
-}
-
#sessionpicker-app {
width: 100%;
height: auto;
diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py
index 604538c..214cca3 100644
--- a/vibe/cli/textual_ui/handlers/event_handler.py
+++ b/vibe/cli/textual_ui/handlers/event_handler.py
@@ -229,10 +229,7 @@ class EventHandler:
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact:
self.current_compact.set_complete(
- old_tokens=event.old_context_tokens,
- new_tokens=event.new_context_tokens,
- old_session_id=event.old_session_id,
- new_session_id=event.new_session_id,
+ old_session_id=event.old_session_id, new_session_id=event.new_session_id
)
self.current_compact = None
diff --git a/vibe/cli/textual_ui/widgets/compact.py b/vibe/cli/textual_ui/widgets/compact.py
index 8d9fc9d..bb40ac3 100644
--- a/vibe/cli/textual_ui/widgets/compact.py
+++ b/vibe/cli/textual_ui/widgets/compact.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from textual.message import Message
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
-from vibe.core.utils import compact_reduction_display
+from vibe.core.utils import compact_complete_display
class CompactMessage(StatusMessage):
@@ -15,8 +15,6 @@ class CompactMessage(StatusMessage):
def __init__(self) -> None:
super().__init__()
self.add_class("compact-message")
- self.old_tokens: int | None = None
- self.new_tokens: int | None = None
self.old_session_id: str | None = None
self.new_session_id: str | None = None
self.error_message: str | None = None
@@ -28,23 +26,13 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
- return compact_reduction_display(
- self.old_tokens,
- self.new_tokens,
- old_session_id=self.old_session_id,
- new_session_id=self.new_session_id,
+ return compact_complete_display(
+ old_session_id=self.old_session_id, new_session_id=self.new_session_id
)
def set_complete(
- self,
- old_tokens: int | None = None,
- new_tokens: int | None = None,
- *,
- old_session_id: str | None = None,
- new_session_id: str | None = None,
+ self, *, old_session_id: str | None = None, new_session_id: str | None = None
) -> None:
- self.old_tokens = old_tokens
- self.new_tokens = new_tokens
self.old_session_id = old_session_id
self.new_session_id = new_session_id
self.stop_spinning(success=True)
diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py
index 15923a3..505433e 100644
--- a/vibe/core/agent_loop.py
+++ b/vibe/core/agent_loop.py
@@ -810,7 +810,6 @@ class AgentLoop: # noqa: PLR0904
)
compact_status: Literal["success", "failure", "cancelled"] = "success"
- new_tokens = self.stats.context_tokens
try:
summary = await self.compact()
except asyncio.CancelledError:
@@ -820,10 +819,8 @@ class AgentLoop: # noqa: PLR0904
compact_status = "failure"
raise
finally:
- new_tokens = self.stats.context_tokens
self.telemetry_client.send_auto_compact_triggered(
nb_context_tokens_before=old_tokens,
- nb_context_tokens_after=new_tokens,
auto_compact_threshold=threshold,
status=compact_status,
session_id=old_session_id,
@@ -832,8 +829,6 @@ class AgentLoop: # noqa: PLR0904
yield CompactEndEvent(
tool_call_id=tool_call_id,
- old_context_tokens=old_tokens,
- new_context_tokens=new_tokens,
summary_length=len(summary),
old_session_id=old_session_id,
new_session_id=self.session_id,
@@ -1724,21 +1719,16 @@ class AgentLoop: # noqa: PLR0904
system_message = self.messages[0]
wrapped_summary = f"{summary_prefix}\n{summary_content}"
- summary_message = LLMMessage(role=Role.user, content=wrapped_summary)
+ summary_message = LLMMessage(
+ role=Role.user, content=wrapped_summary, injected=True
+ )
self.messages.reset([system_message, *prior_user_messages, summary_message])
- active_model = self.config.get_active_model()
await self._reset_session()
- actual_context_tokens = await self.backend.count_tokens(
- model=active_model,
- messages=self.messages,
- tools=self.format_handler.get_available_tools(self.tool_manager),
- extra_headers=self._get_extra_headers(),
- metadata=self._build_backend_metadata().model_dump(exclude_none=True),
- )
-
- self.stats.context_tokens = actual_context_tokens
+ # Context size is unknown without an API call; reset to 0. The next
+ # LLM turn recomputes it accurately from real usage (_update_stats).
+ self.stats.context_tokens = 0
await self.session_logger.save_interaction(
self.messages,
self.stats,
diff --git a/vibe/core/compaction.py b/vibe/core/compaction.py
index 5c9bfa4..4f5d69f 100644
--- a/vibe/core/compaction.py
+++ b/vibe/core/compaction.py
@@ -34,11 +34,13 @@ def collect_prior_user_messages(
content = m.content or ""
cost = approx_token_count(content)
if cost <= remaining:
- selected.append(LLMMessage(role=Role.user, content=content))
+ selected.append(LLMMessage(role=Role.user, content=content, injected=True))
remaining -= cost
else:
truncated = truncate_middle_to_tokens(content, remaining)
- selected.append(LLMMessage(role=Role.user, content=truncated))
+ selected.append(
+ LLMMessage(role=Role.user, content=truncated, injected=True)
+ )
remaining = 0
selected.reverse()
diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py
index 1efa34a..6f77f6e 100644
--- a/vibe/core/config/__init__.py
+++ b/vibe/core/config/__init__.py
@@ -10,6 +10,7 @@ from vibe.core.config._settings import (
DEFAULT_TRANSCRIBE_PROVIDERS,
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
+ DEFAULT_VIBE_BASE_URL,
THINKING_LEVELS,
ConnectorConfig,
ExperimentsConfig,
@@ -76,6 +77,7 @@ __all__ = [
"DEFAULT_TRANSCRIBE_PROVIDERS",
"DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS",
+ "DEFAULT_VIBE_BASE_URL",
"THINKING_LEVELS",
"AppendToList",
"ConfigDefinitionError",
diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py
index f5c3cbe..b834784 100644
--- a/vibe/core/config/_settings.py
+++ b/vibe/core/config/_settings.py
@@ -32,7 +32,7 @@ from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.types import Backend
-from vibe.core.utils import get_server_url_from_api_base
+from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
def _strip_bash_pattern_wildcard(pattern: str) -> str:
@@ -165,6 +165,7 @@ DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
+DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai"
class ProviderConfig(BaseModel):
@@ -520,6 +521,7 @@ class VibeConfig(BaseSettings):
enable_update_checks: bool = True
enable_auto_update: bool = True
enable_notifications: bool = True
+ enable_system_trust_store: bool = False
api_timeout: float = 720.0
auto_compact_threshold: int = 200_000
@@ -537,6 +539,7 @@ class VibeConfig(BaseSettings):
otel_endpoint: str = Field(default="", exclude=True)
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
+ vibe_base_url: str = Field(default=DEFAULT_VIBE_BASE_URL, exclude=True)
enable_experimental_hooks: bool = Field(default=False, exclude=True)
@@ -1064,7 +1067,11 @@ class VibeConfig(BaseSettings):
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()
- return cls(**(overrides or {}))
+ config = cls(**(overrides or {}))
+ configure_ssl_context(
+ enable_system_trust_store=config.enable_system_trust_store
+ )
+ return config
@classmethod
def create_default(cls) -> dict[str, Any]:
diff --git a/vibe/core/llm/backend/anthropic.py b/vibe/core/llm/backend/anthropic.py
index 581f44a..8c3241a 100644
--- a/vibe/core/llm/backend/anthropic.py
+++ b/vibe/core/llm/backend/anthropic.py
@@ -324,12 +324,6 @@ class AnthropicAdapter(APIAdapter):
"prompt-caching-2024-07-31,"
"context-1m-2025-08-07"
)
- THINKING_BUDGETS: ClassVar[dict[str, int]] = {
- "low": 1024,
- "medium": 10_000,
- "high": 32_000,
- "max": 128_000,
- }
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
DEFAULT_MAX_TOKENS = 8192
@@ -375,66 +369,32 @@ class AnthropicAdapter(APIAdapter):
if last_block.get("type") in {"text", "image", "tool_result"}:
last_block["cache_control"] = {"type": "ephemeral"}
- # Anthropic models that require the `thinking={"type":"adaptive"}` +
- # `output_config.effort` shape and reject the older
- # `thinking={"type":"enabled","budget_tokens":...}` shape. Add new
- # adaptive-only model families here as Anthropic ships them.
- ADAPTIVE_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({"opus-4-6", "opus-4-7"})
-
- # Anthropic models that have deprecated the `temperature` parameter and
- # reject any payload containing it. Add new families here as Anthropic
- # ships them.
- TEMPERATURE_DEPRECATED_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({
- "opus-4-7"
- })
-
- @classmethod
- def _is_adaptive_model(cls, model_name: str) -> bool:
- return any(tag in model_name for tag in cls.ADAPTIVE_MODEL_TAGS)
-
- @classmethod
- def _is_temperature_deprecated_model(cls, model_name: str) -> bool:
- return any(tag in model_name for tag in cls.TEMPERATURE_DEPRECATED_MODEL_TAGS)
-
def _apply_thinking_config(
self,
payload: dict[str, Any],
*,
- model_name: str,
messages: list[dict[str, Any]],
- temperature: float,
max_tokens: int | None,
thinking: str,
) -> None:
has_thinking = self._has_thinking_content(messages)
thinking_level = thinking
- temperature_deprecated = self._is_temperature_deprecated_model(model_name)
if thinking_level == "off" and not has_thinking:
- if not temperature_deprecated:
- payload["temperature"] = temperature
- if max_tokens is not None:
- payload["max_tokens"] = max_tokens
- else:
- payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
+ payload["max_tokens"] = (
+ max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS
+ )
return
# Resolve effective level: use config, or fallback to "medium" when
# forced by thinking content in history
effective_level = thinking_level if thinking_level != "off" else "medium"
- if self._is_adaptive_model(model_name):
- payload["thinking"] = {"type": "adaptive", "display": "summarized"}
- payload["output_config"] = {"effort": effective_level}
- default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
- else:
- budget = self.THINKING_BUDGETS[effective_level]
- payload["thinking"] = {"type": "enabled", "budget_tokens": budget}
- default_max = budget + self.DEFAULT_MAX_TOKENS
-
- if not temperature_deprecated:
- payload["temperature"] = 1
- payload["max_tokens"] = max_tokens if max_tokens is not None else default_max
+ payload["thinking"] = {"type": "adaptive", "display": "summarized"}
+ payload["output_config"] = {"effort": effective_level}
+ payload["max_tokens"] = (
+ max_tokens if max_tokens is not None else self.DEFAULT_ADAPTIVE_MAX_TOKENS
+ )
def _build_payload(
self,
@@ -442,7 +402,6 @@ class AnthropicAdapter(APIAdapter):
model_name: str,
system_prompt: str | None,
messages: list[dict[str, Any]],
- temperature: float,
tools: list[dict[str, Any]] | None,
max_tokens: int | None,
tool_choice: dict[str, Any] | None,
@@ -452,12 +411,7 @@ class AnthropicAdapter(APIAdapter):
payload: dict[str, Any] = {"model": model_name, "messages": messages}
self._apply_thinking_config(
- payload,
- model_name=model_name,
- messages=messages,
- temperature=temperature,
- max_tokens=max_tokens,
- thinking=thinking,
+ payload, messages=messages, max_tokens=max_tokens, thinking=thinking
)
if system_blocks := self._build_system_blocks(system_prompt):
@@ -498,7 +452,6 @@ class AnthropicAdapter(APIAdapter):
model_name=model_name,
system_prompt=system_prompt,
messages=converted_messages,
- temperature=temperature,
tools=converted_tools,
max_tokens=max_tokens,
tool_choice=converted_tool_choice,
diff --git a/vibe/core/llm/backend/generic.py b/vibe/core/llm/backend/generic.py
index 2ff3de5..12f6380 100644
--- a/vibe/core/llm/backend/generic.py
+++ b/vibe/core/llm/backend/generic.py
@@ -416,35 +416,6 @@ class GenericBackend:
return
yield json.loads(value.strip())
- async def count_tokens(
- self,
- *,
- model: ModelConfig,
- messages: Sequence[LLMMessage],
- temperature: float = 0.0,
- tools: list[AvailableTool] | None = None,
- tool_choice: StrToolChoice | AvailableTool | None = None,
- extra_headers: dict[str, str] | None = None,
- metadata: dict[str, str] | None = None,
- ) -> int:
- probe_messages = list(messages)
- if not probe_messages or probe_messages[-1].role != Role.user:
- probe_messages.append(LLMMessage(role=Role.user, content=""))
-
- result = await self.complete(
- model=model,
- messages=probe_messages,
- temperature=temperature,
- tools=tools,
- max_tokens=16, # Minimal amount for openrouter with openai models
- tool_choice=tool_choice,
- extra_headers=extra_headers,
- )
- if result.usage is None:
- raise ValueError("Missing usage in non streaming completion")
-
- return result.usage.prompt_tokens
-
async def close(self) -> None:
if self._owns_client and self._client:
await self._client.aclose()
diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py
index 35ef95b..9af363d 100644
--- a/vibe/core/llm/backend/mistral.py
+++ b/vibe/core/llm/backend/mistral.py
@@ -426,29 +426,3 @@ class MistralBackend:
has_tools=bool(tools),
tool_choice=tool_choice,
) from e
-
- async def count_tokens(
- self,
- *,
- model: ModelConfig,
- messages: Sequence[LLMMessage],
- temperature: float = 0.0,
- tools: list[AvailableTool] | None = None,
- tool_choice: StrToolChoice | AvailableTool | None = None,
- extra_headers: dict[str, str] | None = None,
- metadata: dict[str, str] | None = None,
- ) -> int:
- result = await self.complete(
- model=model,
- messages=messages,
- temperature=temperature,
- tools=tools,
- max_tokens=1,
- tool_choice=tool_choice,
- extra_headers=extra_headers,
- metadata=metadata,
- )
- if result.usage is None:
- raise ValueError("Missing usage in non streaming completion")
-
- return result.usage.prompt_tokens
diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py
index 2b3657f..0a400fd 100644
--- a/vibe/core/llm/backend/vertex.py
+++ b/vibe/core/llm/backend/vertex.py
@@ -96,9 +96,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
}
self._apply_thinking_config(
payload,
- model_name=model_name,
messages=converted_messages,
- temperature=temperature,
max_tokens=max_tokens,
thinking=thinking,
)
diff --git a/vibe/core/llm/types.py b/vibe/core/llm/types.py
index c8cc8a8..81af577 100644
--- a/vibe/core/llm/types.py
+++ b/vibe/core/llm/types.py
@@ -14,7 +14,7 @@ class BackendLike(Protocol):
"""Port protocol for dependency-injectable LLM backends.
Any backend used by AgentLoop should implement this async context manager
- interface with `complete`, `complete_streaming` and `count_tokens` methods.
+ interface with `complete` and `complete_streaming` methods.
"""
async def __aenter__(self) -> BackendLike: ...
@@ -92,35 +92,3 @@ class BackendLike(Protocol):
BackendError: If the API request fails
"""
...
-
- async def count_tokens(
- self,
- *,
- model: ModelConfig,
- messages: Sequence[LLMMessage],
- temperature: float = 0.0,
- tools: list[AvailableTool] | None,
- tool_choice: StrToolChoice | AvailableTool | None = None,
- extra_headers: dict[str, str] | None,
- metadata: dict[str, str] | None = None,
- ) -> int:
- """Count the number of tokens in the prompt without generating a real response.
-
- This is useful for:
- - Determining system prompt token count
- - Checking context size after compaction
- - Pre-flight token validation
-
- Args:
- model: Model configuration
- messages: List of messages to count tokens for
- temperature: Sampling temperature
- tools: Optional list of available tools
- tool_choice: How to choose tools
- extra_headers: Additional HTTP headers to include
- metadata: Optional metadata to attach to the request
-
- Returns:
- The number of prompt tokens
- """
- ...
diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py
index 61873c2..1c42260 100644
--- a/vibe/core/skills/builtins/vibe.py
+++ b/vibe/core/skills/builtins/vibe.py
@@ -79,6 +79,7 @@ enable_telemetry = true
enable_update_checks = true
enable_auto_update = true
enable_notifications = true
+enable_system_trust_store = false # Use OS trust store for outbound HTTPS
api_timeout = 720.0 # API request timeout in seconds
auto_compact_threshold = 200000 # Token count before auto-compaction
@@ -267,6 +268,13 @@ browser_auth_base_url = "https://console.mistral.ai"
browser_auth_api_base_url = "https://console.mistral.ai/api"
```
+Self-hosted deployments can point Vibe CLI upgrade and API-key links to their
+Le Chat web deployment, where the Vibe API key is managed:
+
+```toml
+vibe_base_url = "https://chat.mistral.ai"
+```
+
### Hooks (Experimental)
Hooks let users run shell commands automatically at specific points during a
diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py
index 4ec99eb..11488b5 100644
--- a/vibe/core/telemetry/send.py
+++ b/vibe/core/telemetry/send.py
@@ -245,7 +245,6 @@ class TelemetryClient:
self,
*,
nb_context_tokens_before: int,
- nb_context_tokens_after: int,
auto_compact_threshold: int,
status: Literal["success", "failure", "cancelled"],
session_id: str | None = None,
@@ -253,7 +252,6 @@ class TelemetryClient:
) -> None:
payload = {
"nb_context_tokens_before": nb_context_tokens_before,
- "nb_context_tokens_after": nb_context_tokens_after,
"auto_compact_threshold": auto_compact_threshold,
"status": status,
}
diff --git a/vibe/core/tools/mcp/__init__.py b/vibe/core/tools/mcp/__init__.py
index 81cdccc..904cc32 100644
--- a/vibe/core/tools/mcp/__init__.py
+++ b/vibe/core/tools/mcp/__init__.py
@@ -11,6 +11,7 @@ from vibe.core.tools.mcp.tools import (
call_tool_stdio,
create_mcp_http_proxy_tool_class,
create_mcp_stdio_proxy_tool_class,
+ create_vibe_mcp_http_client,
list_tools_http,
list_tools_stdio,
)
@@ -26,6 +27,7 @@ __all__ = [
"call_tool_stdio",
"create_mcp_http_proxy_tool_class",
"create_mcp_stdio_proxy_tool_class",
+ "create_vibe_mcp_http_client",
"list_tools_http",
"list_tools_stdio",
]
diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py
index 51940d1..60885f3 100644
--- a/vibe/core/tools/mcp/tools.py
+++ b/vibe/core/tools/mcp/tools.py
@@ -9,11 +9,12 @@ from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
+import httpx
from pydantic import BaseModel, ConfigDict, Field, field_validator
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
-from mcp.client.streamable_http import streamablehttp_client
+from mcp.client.streamable_http import streamable_http_client
from vibe.core.logger import logger
from vibe.core.tools.base import (
BaseTool,
@@ -25,11 +26,18 @@ from vibe.core.tools.base import (
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
from vibe.core.types import ToolStreamEvent
+from vibe.core.utils.http import build_ssl_context
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
+# Mirrors MCP's default Streamable HTTP timeout values while avoiding an import from
+# mcp.shared._httpx_utils, which is an internal module.
+_MCP_DEFAULT_TIMEOUT = 30.0
+_MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0
+
+
def _stderr_logger_thread(read_fd: int) -> None:
with open(read_fd, "rb") as f:
for line in iter(f.readline, b""):
@@ -167,6 +175,15 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult
return MCPToolResult(server=server, tool=tool, text=text, structured=None)
+def create_vibe_mcp_http_client(headers: dict[str, str] | None) -> httpx.AsyncClient:
+ return httpx.AsyncClient(
+ follow_redirects=True,
+ headers=headers,
+ timeout=httpx.Timeout(_MCP_DEFAULT_TIMEOUT, read=_MCP_DEFAULT_SSE_READ_TIMEOUT),
+ verify=build_ssl_context(),
+ )
+
+
async def list_tools_http(
url: str,
*,
@@ -174,11 +191,18 @@ async def list_tools_http(
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
- async with streamablehttp_client(url, headers=headers) as (read, write, _):
- async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
- await session.initialize()
- tools_resp = await session.list_tools()
- return [RemoteTool.model_validate(t) for t in tools_resp.tools]
+ async with create_vibe_mcp_http_client(headers) as http_client:
+ async with streamable_http_client(url, http_client=http_client) as (
+ read,
+ write,
+ _,
+ ):
+ async with ClientSession(
+ read, write, read_timeout_seconds=timeout
+ ) as session:
+ await session.initialize()
+ tools_resp = await session.list_tools()
+ return [RemoteTool.model_validate(t) for t in tools_resp.tools]
async def call_tool_http(
@@ -195,18 +219,23 @@ async def call_tool_http(
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
- async with streamablehttp_client(url, headers=headers) as (read, write, _):
- async with ClientSession(
+ async with create_vibe_mcp_http_client(headers) as http_client:
+ async with streamable_http_client(url, http_client=http_client) as (
read,
write,
- read_timeout_seconds=init_timeout,
- sampling_callback=sampling_callback,
- ) as session:
- await session.initialize()
- result = await session.call_tool(
- tool_name, arguments, read_timeout_seconds=call_timeout
- )
- return _parse_call_result(url, tool_name, result)
+ _,
+ ):
+ async with ClientSession(
+ read,
+ write,
+ read_timeout_seconds=init_timeout,
+ sampling_callback=sampling_callback,
+ ) as session:
+ await session.initialize()
+ result = await session.call_tool(
+ tool_name, arguments, read_timeout_seconds=call_timeout
+ )
+ return _parse_call_result(url, tool_name, result)
def create_mcp_http_proxy_tool_class(
diff --git a/vibe/core/types.py b/vibe/core/types.py
index d63f11a..21ea638 100644
--- a/vibe/core/types.py
+++ b/vibe/core/types.py
@@ -430,8 +430,6 @@ class CompactStartEvent(BaseEvent):
class CompactEndEvent(BaseEvent):
- old_context_tokens: int
- new_context_tokens: int
summary_length: int
old_session_id: str | None = None
new_session_id: str | None = None
diff --git a/vibe/core/utils/__init__.py b/vibe/core/utils/__init__.py
index b8f7904..5e3776b 100644
--- a/vibe/core/utils/__init__.py
+++ b/vibe/core/utils/__init__.py
@@ -12,9 +12,10 @@ from vibe.core.utils.concurrency import (
ConversationLimitException,
run_sync,
)
-from vibe.core.utils.display import compact_reduction_display
+from vibe.core.utils.display import compact_complete_display
from vibe.core.utils.http import (
build_ssl_context,
+ configure_ssl_context,
get_server_url_from_api_base,
get_user_agent,
)
@@ -55,7 +56,8 @@ __all__ = [
"async_generator_retry",
"async_retry",
"build_ssl_context",
- "compact_reduction_display",
+ "compact_complete_display",
+ "configure_ssl_context",
"get_platform_display_name",
"get_platform_id",
"get_server_url_from_api_base",
diff --git a/vibe/core/utils/display.py b/vibe/core/utils/display.py
index 96f5b70..5ebb62d 100644
--- a/vibe/core/utils/display.py
+++ b/vibe/core/utils/display.py
@@ -3,23 +3,11 @@ from __future__ import annotations
from vibe.core.session.session_id import shorten_session_id
-def compact_reduction_display(
- old_tokens: int | None,
- new_tokens: int | None,
- *,
- old_session_id: str | None = None,
- new_session_id: str | None = None,
+def compact_complete_display(
+ old_session_id: str | None = None, new_session_id: str | None = None
) -> str:
- message = "Compaction complete"
- if old_tokens is not None and new_tokens is not None:
- reduction = old_tokens - new_tokens
- reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
- message = (
- f"{message}: {old_tokens:,} → "
- f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
- )
-
+ message = "Compaction completed."
if old_session_id is not None and new_session_id is not None:
short_old = shorten_session_id(old_session_id)
short_new = shorten_session_id(new_session_id)
diff --git a/vibe/core/utils/http.py b/vibe/core/utils/http.py
index 7616920..c0fbb39 100644
--- a/vibe/core/utils/http.py
+++ b/vibe/core/utils/http.py
@@ -6,14 +6,28 @@ import re
import ssl
import certifi
+import truststore
from vibe import __version__
from vibe.core.types import Backend
+_use_system_trust_store = False
+
+
+def configure_ssl_context(*, enable_system_trust_store: bool) -> None:
+ global _use_system_trust_store
+ if _use_system_trust_store == enable_system_trust_store:
+ return
+ _use_system_trust_store = enable_system_trust_store
+ build_ssl_context.cache_clear()
+
@functools.lru_cache(maxsize=1)
def build_ssl_context() -> ssl.SSLContext:
- ctx = ssl.create_default_context(cafile=certifi.where())
+ if _use_system_trust_store:
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ else:
+ ctx = ssl.create_default_context(cafile=certifi.where())
# Custom certs are additive so private-CA users don't lose public roots.
ssl_cert_file = os.getenv("SSL_CERT_FILE")
diff --git a/vibe/setup/auth/auth_state.py b/vibe/setup/auth/auth_state.py
index ad60693..dc2f58f 100644
--- a/vibe/setup/auth/auth_state.py
+++ b/vibe/setup/auth/auth_state.py
@@ -128,6 +128,7 @@ def assess_auth_state(
return _auth_state(
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True,
+ sign_out_available=True,
env_key=env_key,
)
diff --git a/vibe/setup/onboarding/__init__.py b/vibe/setup/onboarding/__init__.py
index 97cb93a..1883528 100644
--- a/vibe/setup/onboarding/__init__.py
+++ b/vibe/setup/onboarding/__init__.py
@@ -42,6 +42,7 @@ class OnboardingApp(App[str | None]):
self._config = config
self._provider = config.provider
+ self._vibe_base_url = config.vibe_base_url
self._entrypoint_metadata = entrypoint_metadata
self._browser_sign_in_success_delay = browser_sign_in_success_delay
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
@@ -58,7 +59,11 @@ class OnboardingApp(App[str | None]):
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
)
self.install_screen(
- ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata),
+ ApiKeyScreen(
+ self._provider,
+ vibe_base_url=self._vibe_base_url,
+ entrypoint_metadata=self._entrypoint_metadata,
+ ),
"api_key",
)
if self._browser_sign_in_service_factory is not None:
diff --git a/vibe/setup/onboarding/context.py b/vibe/setup/onboarding/context.py
index 1eaadfc..de13912 100644
--- a/vibe/setup/onboarding/context.py
+++ b/vibe/setup/onboarding/context.py
@@ -12,6 +12,7 @@ from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
+ DEFAULT_VIBE_BASE_URL,
)
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
@@ -29,6 +30,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
+ vibe_base_url: str = DEFAULT_VIBE_BASE_URL
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
@@ -97,6 +99,12 @@ def _load_onboarding_env_payload_for_fields(
and (models := _find_env_value("VIBE_MODELS")) is not None
):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
+ if (
+ "vibe_base_url" in field_names
+ and (vibe_base_url := _find_env_value("VIBE_VIBE_BASE_URL")) is not None
+ ):
+ payload["vibe_base_url"] = vibe_base_url
+
return payload
@@ -180,6 +188,7 @@ def _resolve_provider(
@dataclass(frozen=True)
class OnboardingContext:
provider: ProviderConfig
+ vibe_base_url: str = DEFAULT_VIBE_BASE_URL
@property
def supports_browser_sign_in(self) -> bool:
@@ -187,7 +196,9 @@ class OnboardingContext:
@classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext:
- return cls(provider=config.get_active_provider())
+ return cls(
+ provider=config.get_active_provider(), vibe_base_url=config.vibe_base_url
+ )
@classmethod
def load(cls, **overrides: Any) -> OnboardingContext:
@@ -198,7 +209,8 @@ class OnboardingContext:
return cls(
provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot
- )
+ ),
+ vibe_base_url=snapshot.vibe_base_url,
)
except (RuntimeError, ValidationError, ValueError):
logger.warning(
diff --git a/vibe/setup/onboarding/screens/api_key.py b/vibe/setup/onboarding/screens/api_key.py
index 824bd91..e72aa7b 100644
--- a/vibe/setup/onboarding/screens/api_key.py
+++ b/vibe/setup/onboarding/screens/api_key.py
@@ -12,7 +12,7 @@ from textual.widgets import Input, Link
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
-from vibe.core.config import ProviderConfig
+from vibe.core.config import DEFAULT_VIBE_BASE_URL, ProviderConfig
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
@@ -20,8 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
)
from vibe.setup.onboarding.base import OnboardingScreen
-# providers with a dedicated key creation page get a direct onboarding link
-PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")}
+MISTRAL_PROVIDER_NAME = "mistral"
+MISTRAL_PROVIDER_HELP_NAME = "Mistral Vibe"
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
)
@@ -39,17 +39,19 @@ class ApiKeyScreen(OnboardingScreen):
self,
provider: ProviderConfig | None = None,
*,
+ vibe_base_url: str = DEFAULT_VIBE_BASE_URL,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
super().__init__()
self.provider = resolve_api_key_provider(provider)
+ self._vibe_base_url = vibe_base_url
self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self) -> ComposeResult:
- if (help_info := PROVIDER_HELP.get(self.provider.name)) is None:
+ if self.provider.name != MISTRAL_PROVIDER_NAME:
return
- help_url, _ = help_info
+ help_url = f"{self._vibe_base_url.rstrip('/')}/code/extensions?focus=key"
yield Link(help_url, url=help_url, id="api-key-provider-link")
def _compose_config_docs(self) -> ComposeResult:
@@ -60,8 +62,11 @@ class ApiKeyScreen(OnboardingScreen):
def compose(self) -> ComposeResult:
provider_name = self.provider.name.capitalize()
- help_info = PROVIDER_HELP.get(self.provider.name)
- help_name = help_info[1] if help_info is not None else "your provider"
+ help_name = (
+ MISTRAL_PROVIDER_HELP_NAME
+ if self.provider.name == MISTRAL_PROVIDER_NAME
+ else "your provider"
+ )
self.input_widget = Input(
password=True,
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
index 606633e..2d4afde 100644
--- a/vibe/whats_new.md
+++ b/vibe/whats_new.md
@@ -1,4 +1,4 @@
-# What's new in v2.12.1
+# What's new in v2.13.0
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.