v2.13.0 (#733)
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Peter Evers <peter.evers@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
198277af3f
commit
ad0d5c9520
61 changed files with 969 additions and 461 deletions
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -17,7 +17,6 @@ body:
|
|||
options:
|
||||
- CLI
|
||||
- ACP
|
||||
- Both
|
||||
- VS Code extension
|
||||
- Other
|
||||
validations:
|
||||
|
|
|
|||
4
.github/workflows/issue-labeler.yml
vendored
4
.github/workflows/issue-labeler.yml
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
17
CHANGELOG.md
17
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
|
||||
|
|
|
|||
10
README.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
140
tests/backend/test_ssl_integration.py
Normal file
140
tests/backend/test_ssl_integration.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||
import pytest
|
||||
|
||||
from tests.e2e.mock_server import StreamingMockServer
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.llm.backend.generic import GenericBackend
|
||||
from vibe.core.types import Backend, LLMMessage, Role
|
||||
from vibe.core.utils import build_ssl_context, configure_ssl_context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TLSMaterial:
|
||||
ca_file: str
|
||||
cert_file: str
|
||||
key_file: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _HttpsStreamingMockServer:
|
||||
server: StreamingMockServer
|
||||
ca_file: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def https_streaming_mock_server(tmp_path: Path) -> Iterator[_HttpsStreamingMockServer]:
|
||||
tls = _write_tls_material(tmp_path)
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(certfile=tls.cert_file, keyfile=tls.key_file)
|
||||
server = StreamingMockServer(ssl_context=ssl_context)
|
||||
server.start()
|
||||
try:
|
||||
yield _HttpsStreamingMockServer(server=server, ca_file=tls.ca_file)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_backend_streaming_uses_ssl_cert_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
https_streaming_mock_server: _HttpsStreamingMockServer,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SSL_CERT_FILE", https_streaming_mock_server.ca_file)
|
||||
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||
configure_ssl_context(enable_system_trust_store=False)
|
||||
build_ssl_context.cache_clear()
|
||||
|
||||
chunks = []
|
||||
try:
|
||||
provider = ProviderConfig(
|
||||
name="mock-provider",
|
||||
api_base=https_streaming_mock_server.server.api_base,
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.GENERIC,
|
||||
)
|
||||
model = ModelConfig(
|
||||
name="mock-model", provider="mock-provider", alias="mock-model"
|
||||
)
|
||||
|
||||
async with GenericBackend(provider=provider, timeout=5.0) as backend:
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in backend.complete_streaming(
|
||||
model=model, messages=[LLMMessage(role=Role.user, content="Greet")]
|
||||
)
|
||||
]
|
||||
finally:
|
||||
configure_ssl_context(enable_system_trust_store=False)
|
||||
build_ssl_context.cache_clear()
|
||||
|
||||
content = "".join(chunk.message.content or "" for chunk in chunks)
|
||||
request_payload = https_streaming_mock_server.server.requests[-1]
|
||||
assert content == "Hello from mock server"
|
||||
assert request_payload.get("stream") is True
|
||||
assert request_payload.get("model") == "mock-model"
|
||||
|
||||
|
||||
def _write_tls_material(tmp_path: Path) -> _TLSMaterial:
|
||||
now = datetime.now(UTC)
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Vibe test CA")])
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(ca_name)
|
||||
.issuer_name(ca_name)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
|
||||
server_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(server_name)
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(server_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName([x509.IPAddress(IPv4Address("127.0.0.1"))]),
|
||||
critical=False,
|
||||
)
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False
|
||||
)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
cert_file = tmp_path / "server.pem"
|
||||
key_file = tmp_path / "server.key"
|
||||
ca_file.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
cert_file.write_bytes(server_cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_file.write_bytes(
|
||||
server_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
return _TLSMaterial(
|
||||
ca_file=str(ca_file), cert_file=str(cert_file), key_file=str(key_file)
|
||||
)
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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) → "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -129,13 +129,13 @@
|
|||
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)"> ⢠⢢    ⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)"> ⢸⢸⣀⡔⢉⠱⣃⡐⣔⡣</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)"> ⠈⠒⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="48.8" y="117.6" textLength="134.2" clip-path="url(#terminal-line-4)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="48.8" y="142" textLength="134.2" clip-path="url(#terminal-line-5)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r2" x="48.8" y="166.4" textLength="292.8" clip-path="url(#terminal-line-6)">Get your Mistral API key</text><text class="terminal-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r3" x="48.8" y="190.8" textLength="597.8" clip-path="url(#terminal-line-7)">Visit AI Studio to generate or copy your Vibe key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r3" x="48.8" y="190.8" textLength="634.4" clip-path="url(#terminal-line-7)">Visit Mistral Vibe to generate or copy your Vibe key</text><text class="terminal-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="488" clip-path="url(#terminal-line-9)">https://console.mistral.ai/codestral/cli</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="597.8" clip-path="url(#terminal-line-9)">https://chat.mistral.ai/code/extensions?focus=key</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r5" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">┌─</text><text class="terminal-r3" x="73.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)"> Paste API key </text><text class="terminal-r5" x="256.2" y="312.8" textLength="695.4" clip-path="url(#terminal-line-12)">────────────────────────────────────────────────────────┐</text><text class="terminal-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
|
@ -189,7 +189,7 @@
|
|||
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">┃</text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">• Feature 2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">• Feature 3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch to your </text><text class="terminal-r7" x="207.4" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Le Chat Pro API key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r6" x="24.4" y="654.4" textLength="183" clip-path="url(#terminal-line-26)">Switch to your </text><text class="terminal-r7" x="207.4" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Vibe Pro API key</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -189,7 +189,7 @@
|
|||
</text><text class="terminal-r4" x="0" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">┃</text><text class="terminal-r1" x="24.4" y="581.2" textLength="134.2" clip-path="url(#terminal-line-23)">• Feature 2</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r1" x="24.4" y="605.6" textLength="134.2" clip-path="url(#terminal-line-24)">• Feature 3</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock more with Vibe - </text><text class="terminal-r7" x="317.2" y="654.4" textLength="268.4" clip-path="url(#terminal-line-26)">Upgrade to Le Chat Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r6" x="24.4" y="654.4" textLength="292.8" clip-path="url(#terminal-line-26)">Unlock more with Vibe - </text><text class="terminal-r7" x="317.2" y="654.4" textLength="231.8" clip-path="url(#terminal-line-26)">Upgrade to Vibe Pro</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == "<final>"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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="<summary>")],
|
||||
[mock_llm_chunk(content="<final>")],
|
||||
|
|
@ -358,32 +358,24 @@ async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> No
|
|||
|
||||
[_ async for _ in agent.act("Hello")]
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == "<final>"
|
||||
|
||||
roles = [r for r, _ in observed]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
116
uv.lock
generated
116
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue