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:
|
options:
|
||||||
- CLI
|
- CLI
|
||||||
- ACP
|
- ACP
|
||||||
- Both
|
|
||||||
- VS Code extension
|
- VS Code extension
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
|
|
|
||||||
4
.github/workflows/issue-labeler.yml
vendored
4
.github/workflows/issue-labeler.yml
vendored
|
|
@ -47,8 +47,8 @@ jobs:
|
||||||
labels.push('CLI');
|
labels.push('CLI');
|
||||||
} else if (component === 'ACP') {
|
} else if (component === 'ACP') {
|
||||||
labels.push('ACP');
|
labels.push('ACP');
|
||||||
} else if (component === 'Both') {
|
} else if (component === 'VS Code extension') {
|
||||||
labels.push('CLI', 'ACP');
|
labels.push('VS Code extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labels.length > 0) {
|
if (labels.length > 0) {
|
||||||
|
|
|
||||||
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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.13.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `enable_system_trust_store` config flag to switch the shared SSL context to the OS trust store for corporate TLS / private CA setups
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- MCP HTTP transport now uses Vibe's shared SSL context so it honors `SSL_CERT_FILE` / `SSL_CERT_DIR` and the system trust store opt-in
|
||||||
|
- API key onboarding and plan-upgrade CTAs now link to the new Mistral Vibe Code extensions page
|
||||||
|
- Compaction summaries are now injected into the conversation instead of replacing it
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Crash during initialization
|
||||||
|
- VS Code extension promo banner now sits at the top of the conversation instead of being pinned above the input
|
||||||
|
|
||||||
## [2.12.1] - 2026-05-27
|
## [2.12.1] - 2026-05-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
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`.
|
**Note**: The `.env` file is specifically for API keys and other provider credentials. General Vibe configuration should be done in `config.toml`.
|
||||||
|
|
||||||
|
### TLS and Corporate Certificate Authorities
|
||||||
|
|
||||||
|
By default, Vibe uses the bundled `certifi` certificate roots for outbound HTTPS requests. If your organization installs private certificate authorities in the operating system trust store, you can opt in to the system trust store in `config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
enable_system_trust_store = true
|
||||||
|
```
|
||||||
|
|
||||||
|
`SSL_CERT_FILE` and `SSL_CERT_DIR` are still supported and are loaded as additional trust anchors.
|
||||||
|
|
||||||
### Custom System Prompts
|
### Custom System Prompts
|
||||||
|
|
||||||
You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt.
|
You can create `AGENTS.md` files to add custom instructions. You can also replace the entire system prompt.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.12.1"
|
version = "2.13.0"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-aarch64-2.12.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-aarch64-2.13.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-darwin-x86_64-2.12.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-darwin-x86_64-2.13.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-aarch64-2.12.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-aarch64-2.13.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-linux-x86_64-2.12.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-linux-x86_64-2.13.0.zip"
|
||||||
cmd = "./vibe-acp"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
||||||
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.12.1/vibe-acp-windows-x86_64-2.12.1.zip"
|
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.13.0/vibe-acp-windows-x86_64-2.13.0.zip"
|
||||||
cmd = "./vibe-acp.exe"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.12.1"
|
version = "2.13.0"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
@ -64,7 +64,7 @@ dependencies = [
|
||||||
"linkify-it-py==2.1.0",
|
"linkify-it-py==2.1.0",
|
||||||
"markdown-it-py==4.0.0",
|
"markdown-it-py==4.0.0",
|
||||||
"markdownify==1.2.2",
|
"markdownify==1.2.2",
|
||||||
"mcp==1.27.0",
|
"mcp==1.27.1",
|
||||||
"mdit-py-plugins==0.5.0",
|
"mdit-py-plugins==0.5.0",
|
||||||
"mdurl==0.1.2",
|
"mdurl==0.1.2",
|
||||||
"mistralai==2.4.4",
|
"mistralai==2.4.4",
|
||||||
|
|
@ -111,6 +111,7 @@ dependencies = [
|
||||||
"tomli-w==1.2.0",
|
"tomli-w==1.2.0",
|
||||||
"tree-sitter==0.25.2",
|
"tree-sitter==0.25.2",
|
||||||
"tree-sitter-bash==0.25.1",
|
"tree-sitter-bash==0.25.1",
|
||||||
|
"truststore==0.10.4",
|
||||||
"typing-extensions==4.15.0",
|
"typing-extensions==4.15.0",
|
||||||
"typing-inspection==0.4.2",
|
"typing-inspection==0.4.2",
|
||||||
"uc-micro-py==2.0.0",
|
"uc-micro-py==2.0.0",
|
||||||
|
|
@ -173,14 +174,65 @@ dev = [
|
||||||
|
|
||||||
build = [
|
build = [
|
||||||
"altgraph==0.17.5",
|
"altgraph==0.17.5",
|
||||||
|
"anyio==4.13.0",
|
||||||
|
"certifi==2026.4.22",
|
||||||
|
"cffi==2.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
|
||||||
|
"cfgv==3.5.0",
|
||||||
|
"charset-normalizer==3.4.7",
|
||||||
|
"colorama==0.4.6 ; sys_platform == 'win32'",
|
||||||
|
"cryptography==47.0.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
|
||||||
|
"distlib==0.4.0",
|
||||||
|
"docutils==0.22.4",
|
||||||
|
"execnet==2.1.2",
|
||||||
|
"filelock==3.29.0",
|
||||||
|
"h11==0.16.0",
|
||||||
|
"httpcore==1.0.9",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"id==1.6.1",
|
||||||
|
"identify==2.6.19",
|
||||||
|
"idna==3.13",
|
||||||
|
"iniconfig==2.3.0",
|
||||||
|
"jaraco-classes==3.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
|
||||||
|
"jaraco-context==6.1.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
|
||||||
|
"jaraco-functools==4.4.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
|
||||||
|
"jeepney==0.9.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
|
||||||
|
"jinja2==3.1.6",
|
||||||
|
"keyring==25.7.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
|
||||||
|
"linkify-it-py==2.1.0",
|
||||||
"macholib==1.16.4 ; sys_platform == 'darwin'",
|
"macholib==1.16.4 ; sys_platform == 'darwin'",
|
||||||
|
"markdown-it-py==4.0.0",
|
||||||
|
"markupsafe==3.0.3",
|
||||||
|
"mdit-py-plugins==0.5.0",
|
||||||
|
"mdurl==0.1.2",
|
||||||
|
"more-itertools==11.0.2 ; platform_machine != 'ppc64le' and platform_machine != 's390x'",
|
||||||
|
"nh3==0.3.5",
|
||||||
|
"nodeenv==1.10.0",
|
||||||
"packaging==26.2",
|
"packaging==26.2",
|
||||||
"pefile==2024.8.26 ; sys_platform == 'win32'",
|
"pefile==2024.8.26 ; sys_platform == 'win32'",
|
||||||
|
"platformdirs==4.9.6",
|
||||||
|
"pluggy==1.6.0",
|
||||||
|
"pycparser==3.0 ; implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'",
|
||||||
|
"pygments==2.20.0",
|
||||||
"pyinstaller==6.20.0",
|
"pyinstaller==6.20.0",
|
||||||
"pyinstaller-hooks-contrib==2026.4",
|
"pyinstaller-hooks-contrib==2026.4",
|
||||||
|
"pyinstrument==5.1.2",
|
||||||
|
"python-discovery==1.2.2",
|
||||||
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
|
"pywin32-ctypes==0.2.3 ; sys_platform == 'win32'",
|
||||||
|
"pyyaml==6.0.3",
|
||||||
|
"readme-renderer==44.0",
|
||||||
|
"requests==2.33.1",
|
||||||
|
"requests-toolbelt==1.0.0",
|
||||||
|
"rfc3986==2.0.0",
|
||||||
|
"rich==15.0.0",
|
||||||
|
"secretstorage==3.5.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'",
|
||||||
"setuptools==82.0.1",
|
"setuptools==82.0.1",
|
||||||
|
"syrupy==4.8.0",
|
||||||
|
"textual==8.2.7",
|
||||||
"truststore==0.10.4",
|
"truststore==0.10.4",
|
||||||
|
"typing-extensions==4.15.0",
|
||||||
|
"uc-micro-py==2.0.0",
|
||||||
|
"urllib3==2.6.3",
|
||||||
|
"virtualenv==21.2.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ class TestACPAuthStatus:
|
||||||
assert response == {
|
assert response == {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
|
"authState": AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
|
||||||
"signOutAvailable": False,
|
"signOutAvailable": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -213,22 +213,23 @@ class TestACPAuthSignOut:
|
||||||
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refuses_combined_dotenv_and_process_env_key(
|
async def test_removes_dotenv_key_and_restores_process_env_key(
|
||||||
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
monkeypatch.setenv(DEFAULT_MISTRAL_API_ENV_KEY, "process-key")
|
||||||
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
write_env_file(config_dir, f"{DEFAULT_MISTRAL_API_ENV_KEY}=file-key\n")
|
||||||
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
acp_agent_loop = build_acp_agent_loop(build_mistral_provider())
|
||||||
|
|
||||||
with pytest.raises(
|
response = await acp_agent_loop.ext_method("auth/signOut", {})
|
||||||
InvalidRequestError,
|
|
||||||
match=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV.value,
|
|
||||||
):
|
|
||||||
await acp_agent_loop.ext_method("auth/signOut", {})
|
|
||||||
|
|
||||||
assert dotenv_values(config_dir / ".env")[DEFAULT_MISTRAL_API_ENV_KEY] == (
|
assert response == {}
|
||||||
"file-key"
|
assert DEFAULT_MISTRAL_API_ENV_KEY not in dotenv_values(config_dir / ".env")
|
||||||
)
|
assert os.environ[DEFAULT_MISTRAL_API_ENV_KEY] == "process-key"
|
||||||
|
assert await acp_agent_loop.ext_method("auth/status", {}) == {
|
||||||
|
"authenticated": True,
|
||||||
|
"authState": AuthStateKind.PROCESS_ENV.value,
|
||||||
|
"signOutAvailable": False,
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_refuses_unsupported_provider_key(
|
async def test_refuses_unsupported_provider_key(
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
@ -94,7 +94,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
assert response.agent_info == Implementation(
|
||||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.12.1"
|
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.13.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.auth_methods is not None
|
assert response.auth_methods is not None
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ class TestAdapterPrepareRequest:
|
||||||
payload = json.loads(req.body)
|
payload = json.loads(req.body)
|
||||||
assert payload["model"] == "claude-sonnet-4-20250514"
|
assert payload["model"] == "claude-sonnet-4-20250514"
|
||||||
assert payload["max_tokens"] == 1024
|
assert payload["max_tokens"] == 1024
|
||||||
assert payload["temperature"] == 0.5
|
assert "temperature" not in payload
|
||||||
assert req.endpoint == "/v1/messages"
|
assert req.endpoint == "/v1/messages"
|
||||||
assert req.headers["anthropic-version"] == "2023-06-01"
|
assert req.headers["anthropic-version"] == "2023-06-01"
|
||||||
|
|
||||||
|
|
@ -392,9 +392,10 @@ class TestAdapterPrepareRequest:
|
||||||
thinking="medium",
|
thinking="medium",
|
||||||
)
|
)
|
||||||
payload = json.loads(req.body)
|
payload = json.loads(req.body)
|
||||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
|
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
|
assert payload["output_config"] == {"effort": "medium"}
|
||||||
assert payload["max_tokens"] == 1024
|
assert payload["max_tokens"] == 1024
|
||||||
assert payload["temperature"] == 1
|
assert "temperature" not in payload
|
||||||
|
|
||||||
def test_system_cached(self, adapter, provider):
|
def test_system_cached(self, adapter, provider):
|
||||||
messages = [
|
messages = [
|
||||||
|
|
@ -439,12 +440,8 @@ class TestAdapterPrepareRequest:
|
||||||
assert len(payload["tools"]) == 1
|
assert len(payload["tools"]) == 1
|
||||||
assert payload["tools"][0]["name"] == "test_tool"
|
assert payload["tools"][0]["name"] == "test_tool"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("level", ["low", "medium", "high", "max"])
|
||||||
"level,expected_budget", [("low", 1024), ("medium", 10_000), ("high", 32_000)]
|
def test_thinking_levels(self, adapter, provider, level):
|
||||||
)
|
|
||||||
def test_thinking_levels_budget_model(
|
|
||||||
self, adapter, provider, level, expected_budget
|
|
||||||
):
|
|
||||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||||
req = adapter.prepare_request(
|
req = adapter.prepare_request(
|
||||||
model_name="claude-sonnet-4-20250514",
|
model_name="claude-sonnet-4-20250514",
|
||||||
|
|
@ -458,59 +455,12 @@ class TestAdapterPrepareRequest:
|
||||||
thinking=level,
|
thinking=level,
|
||||||
)
|
)
|
||||||
payload = json.loads(req.body)
|
payload = json.loads(req.body)
|
||||||
assert payload["thinking"] == {
|
|
||||||
"type": "enabled",
|
|
||||||
"budget_tokens": expected_budget,
|
|
||||||
}
|
|
||||||
assert payload["temperature"] == 1
|
|
||||||
assert payload["max_tokens"] == expected_budget + 8192
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"model_name", ["claude-opus-4-6-20260101", "claude-opus-4-7-20260418"]
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize("level", ["low", "medium", "high"])
|
|
||||||
def test_thinking_levels_adaptive_model(self, adapter, provider, model_name, level):
|
|
||||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
|
||||||
req = adapter.prepare_request(
|
|
||||||
model_name=model_name,
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.5,
|
|
||||||
tools=None,
|
|
||||||
max_tokens=None,
|
|
||||||
tool_choice=None,
|
|
||||||
enable_streaming=False,
|
|
||||||
provider=provider,
|
|
||||||
thinking=level,
|
|
||||||
)
|
|
||||||
payload = json.loads(req.body)
|
|
||||||
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert payload["output_config"] == {"effort": level}
|
assert payload["output_config"] == {"effort": level}
|
||||||
if "opus-4-7" in model_name:
|
assert "temperature" not in payload
|
||||||
assert "temperature" not in payload
|
|
||||||
else:
|
|
||||||
assert payload["temperature"] == 1
|
|
||||||
assert payload["max_tokens"] == 32_768
|
assert payload["max_tokens"] == 32_768
|
||||||
|
|
||||||
@pytest.mark.parametrize("thinking_level", ["off", "low", "medium", "high"])
|
def test_history_forced_thinking(self, adapter, provider):
|
||||||
def test_temperature_omitted_for_deprecated_model(
|
|
||||||
self, adapter, provider, thinking_level
|
|
||||||
):
|
|
||||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
|
||||||
req = adapter.prepare_request(
|
|
||||||
model_name="claude-opus-4-7-20260418",
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.5,
|
|
||||||
tools=None,
|
|
||||||
max_tokens=None,
|
|
||||||
tool_choice=None,
|
|
||||||
enable_streaming=False,
|
|
||||||
provider=provider,
|
|
||||||
thinking=thinking_level,
|
|
||||||
)
|
|
||||||
payload = json.loads(req.body)
|
|
||||||
assert "temperature" not in payload
|
|
||||||
|
|
||||||
def test_history_forced_thinking_budget_model(self, adapter, provider):
|
|
||||||
messages = [
|
messages = [
|
||||||
LLMMessage(role=Role.user, content="Hello"),
|
LLMMessage(role=Role.user, content="Hello"),
|
||||||
LLMMessage(
|
LLMMessage(
|
||||||
|
|
@ -532,34 +482,9 @@ class TestAdapterPrepareRequest:
|
||||||
provider=provider,
|
provider=provider,
|
||||||
)
|
)
|
||||||
payload = json.loads(req.body)
|
payload = json.loads(req.body)
|
||||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10_000}
|
|
||||||
assert payload["temperature"] == 1
|
|
||||||
assert payload["max_tokens"] == 18_192
|
|
||||||
|
|
||||||
def test_history_forced_thinking_adaptive_model(self, adapter, provider):
|
|
||||||
messages = [
|
|
||||||
LLMMessage(role=Role.user, content="Hello"),
|
|
||||||
LLMMessage(
|
|
||||||
role=Role.assistant,
|
|
||||||
content="Answer",
|
|
||||||
reasoning_content="thinking...",
|
|
||||||
reasoning_signature="sig",
|
|
||||||
),
|
|
||||||
LLMMessage(role=Role.user, content="Follow up"),
|
|
||||||
]
|
|
||||||
req = adapter.prepare_request(
|
|
||||||
model_name="claude-opus-4-6-20260101",
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.5,
|
|
||||||
tools=None,
|
|
||||||
max_tokens=None,
|
|
||||||
tool_choice=None,
|
|
||||||
enable_streaming=False,
|
|
||||||
provider=provider,
|
|
||||||
)
|
|
||||||
payload = json.loads(req.body)
|
|
||||||
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
assert payload["output_config"] == {"effort": "medium"}
|
assert payload["output_config"] == {"effort": "medium"}
|
||||||
|
assert "temperature" not in payload
|
||||||
assert payload["max_tokens"] == 32_768
|
assert payload["max_tokens"] == 32_768
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 payload["anthropic_version"] == "vertex-2023-10-16"
|
||||||
assert "model" not in payload
|
assert "model" not in payload
|
||||||
assert payload["max_tokens"] == 1024
|
assert payload["max_tokens"] == 1024
|
||||||
assert payload["temperature"] == 0.5
|
assert "temperature" not in payload
|
||||||
assert req.headers["Authorization"] == "Bearer fake-token"
|
assert req.headers["Authorization"] == "Bearer fake-token"
|
||||||
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
|
assert req.headers["anthropic-beta"] == adapter.BETA_FEATURES
|
||||||
assert "rawPredict" in req.endpoint
|
assert "rawPredict" in req.endpoint
|
||||||
|
|
@ -147,9 +147,10 @@ class TestPrepareRequest:
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = json.loads(req.body)
|
payload = json.loads(req.body)
|
||||||
assert payload["thinking"] == {"type": "enabled", "budget_tokens": 10000}
|
assert payload["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||||
|
assert payload["output_config"] == {"effort": "medium"}
|
||||||
assert payload["max_tokens"] == 1024
|
assert payload["max_tokens"] == 1024
|
||||||
assert payload["temperature"] == 1
|
assert "temperature" not in payload
|
||||||
|
|
||||||
def test_with_tools(self, adapter, provider):
|
def test_with_tools(self, adapter, provider):
|
||||||
messages = [LLMMessage(role=Role.user, content="Hello")]
|
messages = [LLMMessage(role=Role.user, content="Hello")]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
||||||
PlanInfo,
|
PlanInfo,
|
||||||
WhoAmIPlanType,
|
WhoAmIPlanType,
|
||||||
decide_plan_offer,
|
decide_plan_offer,
|
||||||
|
plan_offer_cta,
|
||||||
resolve_api_key_for_plan,
|
resolve_api_key_for_plan,
|
||||||
)
|
)
|
||||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||||
|
|
@ -174,6 +175,47 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
|
||||||
environ["MISTRAL_API_KEY"] = previous_api_key
|
environ["MISTRAL_API_KEY"] = previous_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("plan_info", "expected_cta"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
PlanInfo(
|
||||||
|
plan_type=WhoAmIPlanType.CHAT,
|
||||||
|
plan_name="INDIVIDUAL",
|
||||||
|
prompt_switching_to_pro_plan=True,
|
||||||
|
),
|
||||||
|
"### Switch to your [Vibe Pro API key](https://chat.mistral.ai/code/extensions?focus=key)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
PlanInfo(
|
||||||
|
plan_type=WhoAmIPlanType.API,
|
||||||
|
plan_name="FREE",
|
||||||
|
prompt_switching_to_pro_plan=False,
|
||||||
|
),
|
||||||
|
"### Unlock more with Vibe - [Upgrade to Vibe Pro](https://chat.mistral.ai/code/extensions?focus=key)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["switch-to-vibe-pro-key", "upgrade-to-vibe-pro"],
|
||||||
|
)
|
||||||
|
def test_plan_offer_cta_routes_users_to_vibe_api_key_extensions(
|
||||||
|
plan_info: PlanInfo, expected_cta: str
|
||||||
|
) -> None:
|
||||||
|
assert plan_offer_cta(plan_info) == expected_cta
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_offer_cta_uses_configured_vibe_url() -> None:
|
||||||
|
plan_info = PlanInfo(
|
||||||
|
plan_type=WhoAmIPlanType.CHAT,
|
||||||
|
plan_name="INDIVIDUAL",
|
||||||
|
prompt_switching_to_pro_plan=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
plan_offer_cta(plan_info, vibe_base_url="https://vibe.example.com/")
|
||||||
|
== "### Switch to your [Vibe Pro API key](https://vibe.example.com/code/extensions?focus=key)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("response", "expected"),
|
("response", "expected"),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,12 @@ class TestCompactMessage:
|
||||||
message.post_message = MagicMock()
|
message.post_message = MagicMock()
|
||||||
|
|
||||||
message.set_complete(
|
message.set_complete(
|
||||||
old_tokens=177_017,
|
|
||||||
new_tokens=23_263,
|
|
||||||
old_session_id="11111111-1111-1111-1111-111111111111",
|
old_session_id="11111111-1111-1111-1111-111111111111",
|
||||||
new_session_id="22222222-2222-2222-2222-222222222222",
|
new_session_id="22222222-2222-2222-2222-222222222222",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert message.get_content() == (
|
assert message.get_content() == (
|
||||||
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
|
"Compaction completed.\n"
|
||||||
"session: "
|
"session: "
|
||||||
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
|
f"{shorten_session_id('11111111-1111-1111-1111-111111111111')} "
|
||||||
"(before compaction) → "
|
"(before compaction) → "
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.utils.http import build_ssl_context
|
from vibe.core.utils.http import build_ssl_context, configure_ssl_context
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_ssl_cache():
|
def _clear_ssl_cache():
|
||||||
|
configure_ssl_context(enable_system_trust_store=False)
|
||||||
build_ssl_context.cache_clear()
|
build_ssl_context.cache_clear()
|
||||||
yield
|
yield
|
||||||
|
configure_ssl_context(enable_system_trust_store=False)
|
||||||
build_ssl_context.cache_clear()
|
build_ssl_context.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,6 +22,42 @@ def test_build_ssl_context_returns_ssl_context():
|
||||||
assert isinstance(ctx, ssl.SSLContext)
|
assert isinstance(ctx, ssl.SSLContext)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ssl_context_uses_certifi_by_default(monkeypatch):
|
||||||
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||||
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||||
|
|
||||||
|
mock_ctx = MagicMock(spec=ssl.SSLContext)
|
||||||
|
with (
|
||||||
|
patch("vibe.core.utils.http.certifi.where", return_value="/certifi.pem"),
|
||||||
|
patch(
|
||||||
|
"vibe.core.utils.http.ssl.create_default_context", return_value=mock_ctx
|
||||||
|
) as create_default_context,
|
||||||
|
):
|
||||||
|
build_ssl_context()
|
||||||
|
|
||||||
|
create_default_context.assert_called_once_with(cafile="/certifi.pem")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ssl_context_uses_system_trust_store_when_configured(monkeypatch):
|
||||||
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||||
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||||
|
configure_ssl_context(enable_system_trust_store=True)
|
||||||
|
|
||||||
|
mock_ctx = MagicMock(spec=ssl.SSLContext)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx
|
||||||
|
) as truststore_context,
|
||||||
|
patch(
|
||||||
|
"vibe.core.utils.http.ssl.create_default_context"
|
||||||
|
) as create_default_context,
|
||||||
|
):
|
||||||
|
build_ssl_context()
|
||||||
|
|
||||||
|
truststore_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
create_default_context.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
|
def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
|
||||||
cert_file = tmp_path / "custom.pem"
|
cert_file = tmp_path / "custom.pem"
|
||||||
cert_file.write_text("dummy")
|
cert_file.write_text("dummy")
|
||||||
|
|
@ -37,6 +75,24 @@ def test_build_ssl_context_loads_custom_cert_file(monkeypatch, tmp_path):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ssl_context_loads_custom_cert_file_with_system_trust_store(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
cert_file = tmp_path / "custom.pem"
|
||||||
|
cert_file.write_text("dummy")
|
||||||
|
monkeypatch.setenv("SSL_CERT_FILE", str(cert_file))
|
||||||
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||||
|
configure_ssl_context(enable_system_trust_store=True)
|
||||||
|
|
||||||
|
mock_ctx = MagicMock(spec=ssl.SSLContext)
|
||||||
|
with patch("vibe.core.utils.http.truststore.SSLContext", return_value=mock_ctx):
|
||||||
|
build_ssl_context()
|
||||||
|
|
||||||
|
mock_ctx.load_verify_locations.assert_called_once_with(
|
||||||
|
cafile=str(cert_file), capath=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path):
|
def test_build_ssl_context_loads_custom_cert_dir(monkeypatch, tmp_path):
|
||||||
cert_dir = tmp_path / "certs"
|
cert_dir = tmp_path / "certs"
|
||||||
cert_dir.mkdir()
|
cert_dir.mkdir()
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import ssl
|
||||||
import tomllib
|
import tomllib
|
||||||
from typing import Literal, TypedDict, Unpack
|
from typing import Literal, TypedDict, Unpack
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
@ -23,6 +25,7 @@ from vibe.core.config.harness_files import (
|
||||||
from vibe.core.paths import VIBE_HOME
|
from vibe.core.paths import VIBE_HOME
|
||||||
from vibe.core.trusted_folders import trusted_folders_manager
|
from vibe.core.trusted_folders import trusted_folders_manager
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
|
from vibe.core.utils.http import build_ssl_context, configure_ssl_context
|
||||||
from vibe.setup.onboarding.context import OnboardingContext
|
from vibe.setup.onboarding.context import OnboardingContext
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,6 +201,73 @@ class TestSaveUpdates:
|
||||||
assert result == {"tools": {"bash": {"default_timeout": 600}}}
|
assert result == {"tools": {"bash": {"default_timeout": 600}}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemTrustStoreConfig:
|
||||||
|
def test_load_configures_ssl_context_from_toml(self, config_dir: Path) -> None:
|
||||||
|
config_file = config_dir / "config.toml"
|
||||||
|
with config_file.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
data["enable_system_trust_store"] = True
|
||||||
|
with config_file.open("wb") as f:
|
||||||
|
tomli_w.dump(data, f)
|
||||||
|
|
||||||
|
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
|
||||||
|
config = VibeConfig.load()
|
||||||
|
|
||||||
|
assert config.enable_system_trust_store is True
|
||||||
|
configure.assert_called_once_with(enable_system_trust_store=True)
|
||||||
|
|
||||||
|
def test_load_configures_ssl_context_from_env(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("VIBE_ENABLE_SYSTEM_TRUST_STORE", "true")
|
||||||
|
|
||||||
|
with patch("vibe.core.config._settings.configure_ssl_context") as configure:
|
||||||
|
config = VibeConfig.load()
|
||||||
|
|
||||||
|
assert config.enable_system_trust_store is True
|
||||||
|
configure.assert_called_once_with(enable_system_trust_store=True)
|
||||||
|
|
||||||
|
def test_load_clears_cached_ssl_context_when_setting_changes(
|
||||||
|
self, config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
|
||||||
|
monkeypatch.delenv("SSL_CERT_DIR", raising=False)
|
||||||
|
configure_ssl_context(enable_system_trust_store=False)
|
||||||
|
build_ssl_context.cache_clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_file = config_dir / "config.toml"
|
||||||
|
with config_file.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
data["enable_system_trust_store"] = False
|
||||||
|
with config_file.open("wb") as f:
|
||||||
|
tomli_w.dump(data, f)
|
||||||
|
|
||||||
|
default_ctx = MagicMock(spec=ssl.SSLContext)
|
||||||
|
with patch(
|
||||||
|
"vibe.core.utils.http.ssl.create_default_context",
|
||||||
|
return_value=default_ctx,
|
||||||
|
):
|
||||||
|
VibeConfig.load()
|
||||||
|
assert build_ssl_context() is default_ctx
|
||||||
|
|
||||||
|
data["enable_system_trust_store"] = True
|
||||||
|
with config_file.open("wb") as f:
|
||||||
|
tomli_w.dump(data, f)
|
||||||
|
|
||||||
|
truststore_ctx = MagicMock(spec=ssl.SSLContext)
|
||||||
|
with patch(
|
||||||
|
"vibe.core.utils.http.truststore.SSLContext",
|
||||||
|
return_value=truststore_ctx,
|
||||||
|
):
|
||||||
|
VibeConfig.load()
|
||||||
|
assert build_ssl_context() is truststore_ctx
|
||||||
|
finally:
|
||||||
|
configure_ssl_context(enable_system_trust_store=False)
|
||||||
|
build_ssl_context.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
class TestSetThinking:
|
class TestSetThinking:
|
||||||
def test_persists_thinking_to_toml(self, config_dir: Path) -> None:
|
def test_persists_thinking_to_toml(self, config_dir: Path) -> None:
|
||||||
config_file = config_dir / "config.toml"
|
config_file = config_dir / "config.toml"
|
||||||
|
|
@ -821,6 +891,7 @@ class TestOnboardingContextResolution:
|
||||||
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_load_uses_env_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
reset_harness_files_manager()
|
reset_harness_files_manager()
|
||||||
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model")
|
||||||
|
monkeypatch.setenv("VIBE_VIBE_BASE_URL", "https://env-vibe.example.com")
|
||||||
monkeypatch.setenv(
|
monkeypatch.setenv(
|
||||||
"VIBE_PROVIDERS",
|
"VIBE_PROVIDERS",
|
||||||
json.dumps([
|
json.dumps([
|
||||||
|
|
@ -842,6 +913,7 @@ class TestOnboardingContextResolution:
|
||||||
|
|
||||||
assert context.provider.name == "env-provider"
|
assert context.provider.name == "env-provider"
|
||||||
assert context.provider.api_key_env_var == "ENV_API_KEY"
|
assert context.provider.api_key_env_var == "ENV_API_KEY"
|
||||||
|
assert context.vibe_base_url == "https://env-vibe.example.com"
|
||||||
|
|
||||||
def test_load_prefers_explicit_overrides_over_toml_and_env(
|
def test_load_prefers_explicit_overrides_over_toml_and_env(
|
||||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
|
||||||
|
|
@ -331,17 +331,13 @@ class TestTelemetryClient:
|
||||||
client = TelemetryClient(config_getter=lambda: config)
|
client = TelemetryClient(config_getter=lambda: config)
|
||||||
|
|
||||||
client.send_auto_compact_triggered(
|
client.send_auto_compact_triggered(
|
||||||
nb_context_tokens_before=123,
|
nb_context_tokens_before=123, auto_compact_threshold=100, status="success"
|
||||||
nb_context_tokens_after=45,
|
|
||||||
auto_compact_threshold=100,
|
|
||||||
status="success",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(telemetry_events) == 1
|
assert len(telemetry_events) == 1
|
||||||
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
|
assert telemetry_events[0]["event_name"] == "vibe.auto_compact_triggered"
|
||||||
assert telemetry_events[0]["properties"] == {
|
assert telemetry_events[0]["properties"] == {
|
||||||
"nb_context_tokens_before": 123,
|
"nb_context_tokens_before": 123,
|
||||||
"nb_context_tokens_after": 45,
|
|
||||||
"auto_compact_threshold": 100,
|
"auto_compact_threshold": 100,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
}
|
}
|
||||||
|
|
@ -674,7 +670,6 @@ class TestTelemetryClient:
|
||||||
|
|
||||||
client.send_auto_compact_triggered(
|
client.send_auto_compact_triggered(
|
||||||
nb_context_tokens_before=123,
|
nb_context_tokens_before=123,
|
||||||
nb_context_tokens_after=45,
|
|
||||||
auto_compact_threshold=100,
|
auto_compact_threshold=100,
|
||||||
status="success",
|
status="success",
|
||||||
session_id="original-session-id",
|
session_id="original-session-id",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from vibe.core.utils import compact_reduction_display, get_server_url_from_api_base
|
from vibe.core.utils import compact_complete_display, get_server_url_from_api_base
|
||||||
import vibe.core.utils.io as io_utils
|
import vibe.core.utils.io as io_utils
|
||||||
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
|
from vibe.core.utils.io import decode_safe, read_safe, read_safe_async
|
||||||
|
|
||||||
|
|
@ -23,22 +23,18 @@ def test_get_server_url_from_api_base(api_base, expected):
|
||||||
assert get_server_url_from_api_base(api_base) == expected
|
assert get_server_url_from_api_base(api_base) == expected
|
||||||
|
|
||||||
|
|
||||||
class TestCompactReductionDisplay:
|
class TestCompactCompleteDisplay:
|
||||||
def test_includes_session_ids_when_available(self) -> None:
|
def test_includes_session_ids_when_available(self) -> None:
|
||||||
assert compact_reduction_display(
|
assert compact_complete_display(
|
||||||
177_017,
|
|
||||||
23_263,
|
|
||||||
old_session_id="11111111-1111-1111-1111-111111111111",
|
old_session_id="11111111-1111-1111-1111-111111111111",
|
||||||
new_session_id="22222222-2222-2222-2222-222222222222",
|
new_session_id="22222222-2222-2222-2222-222222222222",
|
||||||
) == (
|
) == (
|
||||||
"Compaction complete: 177,017 → 23,263 tokens (-87.%)\n"
|
"Compaction completed.\n"
|
||||||
"session: 11111111 (before compaction) → 22222222 (after compaction)"
|
"session: 11111111 (before compaction) → 22222222 (after compaction)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_base_message_without_session_ids(self) -> None:
|
def test_returns_base_message_without_session_ids(self) -> None:
|
||||||
assert compact_reduction_display(177_017, 23_263) == (
|
assert compact_complete_display() == "Compaction completed."
|
||||||
"Compaction complete: 177,017 → 23,263 tokens (-87.%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadSafe:
|
class TestReadSafe:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
import json
|
import json
|
||||||
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import TypedDict, cast
|
from typing import TypedDict, cast
|
||||||
|
|
@ -84,11 +85,21 @@ class StreamingMockServer:
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *, chunk_factory: ChunkFactory | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chunk_factory: ChunkFactory | None = None,
|
||||||
|
ssl_context: ssl.SSLContext | None = None,
|
||||||
|
) -> None:
|
||||||
self.requests: list[ChatCompletionsRequestPayload] = []
|
self.requests: list[ChatCompletionsRequestPayload] = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._chunk_factory = chunk_factory
|
self._chunk_factory = chunk_factory
|
||||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
|
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._build_handler())
|
||||||
|
self._scheme = "https" if ssl_context is not None else "http"
|
||||||
|
if ssl_context is not None:
|
||||||
|
self._server.socket = ssl_context.wrap_socket(
|
||||||
|
self._server.socket, server_side=True
|
||||||
|
)
|
||||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||||
|
|
||||||
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
|
def _build_handler(self) -> type[BaseHTTPRequestHandler]:
|
||||||
|
|
@ -138,7 +149,7 @@ class StreamingMockServer:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self) -> str:
|
def api_base(self) -> str:
|
||||||
return f"http://127.0.0.1:{self._server.server_port}/v1"
|
return f"{self._scheme}://127.0.0.1:{self._server.server_port}/v1"
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from textual.geometry import Size
|
||||||
from textual.pilot import Pilot
|
from textual.pilot import Pilot
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Input
|
from textual.widgets import Input, Link
|
||||||
|
|
||||||
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
|
from tests.browser_sign_in.stubs import build_browser_sign_in_service_factory
|
||||||
from tests.conftest import build_test_vibe_config
|
from tests.conftest import build_test_vibe_config
|
||||||
|
|
@ -70,6 +70,7 @@ def _build_onboarding_config(
|
||||||
api_key_env_var: str = "MISTRAL_API_KEY",
|
api_key_env_var: str = "MISTRAL_API_KEY",
|
||||||
browser_auth_base_url: str | None = None,
|
browser_auth_base_url: str | None = None,
|
||||||
browser_auth_api_base_url: str | None = None,
|
browser_auth_api_base_url: str | None = None,
|
||||||
|
vibe_base_url: str = "https://chat.mistral.ai",
|
||||||
) -> VibeConfig:
|
) -> VibeConfig:
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
name=provider_name,
|
name=provider_name,
|
||||||
|
|
@ -84,7 +85,9 @@ def _build_onboarding_config(
|
||||||
provider=model_provider or provider_name,
|
provider=model_provider or provider_name,
|
||||||
alias="devstral-2",
|
alias="devstral-2",
|
||||||
)
|
)
|
||||||
return build_test_vibe_config(providers=[provider], models=[model])
|
return build_test_vibe_config(
|
||||||
|
providers=[provider], models=[model], vibe_base_url=vibe_base_url
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_browser_onboarding_app(
|
def _build_browser_onboarding_app(
|
||||||
|
|
@ -236,6 +239,12 @@ async def _show_browser_sign_in(pilot: Pilot) -> None:
|
||||||
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
|
await _wait_for(lambda: isinstance(pilot.app.screen, BrowserSignInScreen), pilot)
|
||||||
|
|
||||||
|
|
||||||
|
async def _show_manual_api_key_screen(pilot: Pilot) -> None:
|
||||||
|
await _show_auth_method(pilot)
|
||||||
|
await pilot.press("down", "enter")
|
||||||
|
await _wait_for(lambda: isinstance(pilot.app.screen, ApiKeyScreen), pilot)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
|
async def test_ui_keeps_manual_flow_when_browser_sign_in_is_unsupported() -> None:
|
||||||
app = OnboardingApp(
|
app = OnboardingApp(
|
||||||
|
|
@ -892,6 +901,20 @@ def test_api_key_screen_uses_mistral_fallback_for_context_without_env_key(
|
||||||
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
assert screen.provider.api_key_env_var == "MISTRAL_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_manual_api_key_screen_uses_configured_vibe_url() -> None:
|
||||||
|
app = OnboardingApp(
|
||||||
|
config=_build_onboarding_config(vibe_base_url="https://vibe.example.com/")
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await _show_manual_api_key_screen(pilot)
|
||||||
|
|
||||||
|
provider_link = app.screen.query_one("#api-key-provider-link", Link)
|
||||||
|
|
||||||
|
assert provider_link.url == "https://vibe.example.com/code/extensions?focus=key"
|
||||||
|
|
||||||
|
|
||||||
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
|
def test_persist_api_key_returns_save_error_for_invalid_env_var_name() -> None:
|
||||||
provider = ProviderConfig(
|
provider = ProviderConfig(
|
||||||
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
|
name="custom", api_base="https://custom.example/v1", api_key_env_var="BAD=NAME"
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ def test_assess_vibe_home_env_file_overrides_process_env_when_both_sources_exist
|
||||||
assert state == AuthState(
|
assert state == AuthState(
|
||||||
kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
|
kind=AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
|
||||||
can_use_active_provider=True,
|
can_use_active_provider=True,
|
||||||
sign_out_available=False,
|
sign_out_available=True,
|
||||||
env_key=DEFAULT_MISTRAL_API_ENV_KEY,
|
env_key=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,13 +129,13 @@
|
||||||
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
<text class="terminal-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||||
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
</text><text class="terminal-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||||
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
</text><text class="terminal-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||||
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)"> ⢠⢢    ⡔⢄⠔⡄</text><text class="terminal-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
</text><text class="terminal-r1" x="48.8" y="93.2" textLength="134.2" clip-path="url(#terminal-line-3)">  ⡠⣒⠄  ⡔⢄⠔⡄</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="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="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-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-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||||
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="488" clip-path="url(#terminal-line-9)">https://console.mistral.ai/codestral/cli</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
</text><text class="terminal-r4" x="48.8" y="239.6" textLength="597.8" clip-path="url(#terminal-line-9)">https://chat.mistral.ai/code/extensions?focus=key</text><text class="terminal-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||||
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
</text><text class="terminal-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||||
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
</text><text class="terminal-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||||
</text><text class="terminal-r5" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-line-12)">┌─</text><text class="terminal-r3" x="73.2" y="312.8" textLength="183" clip-path="url(#terminal-line-12)"> 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)">
|
</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="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="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="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="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -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="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="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="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="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||||
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||||
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -1,14 +1,23 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
from textual.pilot import Pilot
|
from textual.pilot import Pilot
|
||||||
|
|
||||||
from tests.snapshots.snap_compare import SnapCompare
|
from tests.snapshots.snap_compare import SnapCompare
|
||||||
|
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||||
from vibe.core.config import ProviderConfig
|
from vibe.core.config import ProviderConfig
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||||
|
|
||||||
|
|
||||||
|
class StaticPetitChat(PetitChat):
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(animate=False, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyScreenSnapshotApp(App[str | None]):
|
class ApiKeyScreenSnapshotApp(App[str | None]):
|
||||||
CSS_PATH = "../../vibe/setup/onboarding/onboarding.tcss"
|
CSS_PATH = "../../vibe/setup/onboarding/onboarding.tcss"
|
||||||
|
|
||||||
|
|
@ -27,8 +36,12 @@ class ApiKeyScreenSnapshotApp(App[str | None]):
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_onboarding_api_key_with_valid_input(
|
def test_snapshot_onboarding_api_key_with_valid_input(
|
||||||
snap_compare: SnapCompare,
|
snap_compare: SnapCompare, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"vibe.setup.onboarding.screens.api_key.PetitChat", StaticPetitChat
|
||||||
|
)
|
||||||
|
|
||||||
async def run_before(pilot: Pilot) -> None:
|
async def run_before(pilot: Pilot) -> None:
|
||||||
await pilot.pause(0.2)
|
await pilot.pause(0.2)
|
||||||
await pilot.press(*"sk-test-api-key")
|
await pilot.press(*"sk-test-api-key")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
|
from collections.abc import AsyncGenerator, Iterable
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from tests.mock.utils import mock_llm_chunk
|
from tests.mock.utils import mock_llm_chunk
|
||||||
|
|
@ -21,7 +21,6 @@ class FakeBackend:
|
||||||
| Iterable[Iterable[LLMChunk]]
|
| Iterable[Iterable[LLMChunk]]
|
||||||
| None = None,
|
| None = None,
|
||||||
*,
|
*,
|
||||||
token_counter: Callable[[Sequence[LLMMessage]], int] | None = None,
|
|
||||||
exception_to_raise: Exception | None = None,
|
exception_to_raise: Exception | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fake backend that will output the given chunks in the order they are given.
|
"""Fake backend that will output the given chunks in the order they are given.
|
||||||
|
|
@ -36,8 +35,6 @@ class FakeBackend:
|
||||||
self._requests_messages: list[list[LLMMessage]] = []
|
self._requests_messages: list[list[LLMMessage]] = []
|
||||||
self._requests_extra_headers: list[dict[str, str] | None] = []
|
self._requests_extra_headers: list[dict[str, str] | None] = []
|
||||||
self._requests_metadata: list[dict[str, str] | None] = []
|
self._requests_metadata: list[dict[str, str] | None] = []
|
||||||
self._count_tokens_calls: list[list[LLMMessage]] = []
|
|
||||||
self._token_counter = token_counter or self._default_token_counter
|
|
||||||
self._exception_to_raise = exception_to_raise
|
self._exception_to_raise = exception_to_raise
|
||||||
|
|
||||||
self._streams: list[list[LLMChunk]]
|
self._streams: list[list[LLMChunk]]
|
||||||
|
|
@ -70,10 +67,6 @@ class FakeBackend:
|
||||||
def requests_metadata(self) -> list[dict[str, str] | None]:
|
def requests_metadata(self) -> list[dict[str, str] | None]:
|
||||||
return self._requests_metadata
|
return self._requests_metadata
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _default_token_counter(messages: Sequence[LLMMessage]) -> int:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -133,20 +126,3 @@ class FakeBackend:
|
||||||
stream = [mock_llm_chunk(content="")]
|
stream = [mock_llm_chunk(content="")]
|
||||||
for chunk in stream:
|
for chunk in stream:
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
async def count_tokens(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
temperature=0.0,
|
|
||||||
tools,
|
|
||||||
tool_choice=None,
|
|
||||||
extra_headers,
|
|
||||||
metadata=None,
|
|
||||||
) -> int:
|
|
||||||
self._requests_messages.append(list(messages))
|
|
||||||
self._requests_extra_headers.append(extra_headers)
|
|
||||||
self._requests_metadata.append(metadata)
|
|
||||||
self._count_tokens_calls.append(list(messages))
|
|
||||||
return self._token_counter(messages)
|
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,11 @@ async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -
|
||||||
final: AssistantEvent = events[3]
|
final: AssistantEvent = events[3]
|
||||||
assert start.current_context_tokens == 2
|
assert start.current_context_tokens == 2
|
||||||
assert start.threshold == 1
|
assert start.threshold == 1
|
||||||
assert end.old_context_tokens == 2
|
assert isinstance(end, CompactEndEvent)
|
||||||
assert end.new_context_tokens >= 1
|
|
||||||
assert final.content == "<final>"
|
assert final.content == "<final>"
|
||||||
|
|
||||||
properties = _get_auto_compact_properties(telemetry_events)
|
properties = _get_auto_compact_properties(telemetry_events)
|
||||||
assert properties["nb_context_tokens_before"] == 2
|
assert properties["nb_context_tokens_before"] == 2
|
||||||
assert properties["nb_context_tokens_after"] == end.new_context_tokens
|
|
||||||
assert properties["auto_compact_threshold"] == 1
|
assert properties["auto_compact_threshold"] == 1
|
||||||
assert properties["status"] == "success"
|
assert properties["status"] == "success"
|
||||||
assert properties["session_id"] == old_session_id
|
assert properties["session_id"] == old_session_id
|
||||||
|
|
@ -119,7 +117,6 @@ async def test_auto_compact_emits_terminal_telemetry(
|
||||||
|
|
||||||
properties = _get_auto_compact_properties(telemetry_events)
|
properties = _get_auto_compact_properties(telemetry_events)
|
||||||
assert properties["nb_context_tokens_before"] == 2
|
assert properties["nb_context_tokens_before"] == 2
|
||||||
assert properties["nb_context_tokens_after"] == 2
|
|
||||||
assert properties["auto_compact_threshold"] == 1
|
assert properties["auto_compact_threshold"] == 1
|
||||||
assert properties["status"] == expected_status
|
assert properties["status"] == expected_status
|
||||||
assert properties["session_id"] == old_session_id
|
assert properties["session_id"] == old_session_id
|
||||||
|
|
|
||||||
|
|
@ -335,8 +335,8 @@ async def test_mistral_metadata_header_call_type_per_turn() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> None:
|
async def test_auto_compact_emits_summary_and_next_turn_metadata() -> None:
|
||||||
"""Compact emits summary, token-count, then user-turn backend metadata in order."""
|
"""Compact emits summary then user-turn backend metadata in order."""
|
||||||
backend = FakeBackend([
|
backend = FakeBackend([
|
||||||
[mock_llm_chunk(content="<summary>")],
|
[mock_llm_chunk(content="<summary>")],
|
||||||
[mock_llm_chunk(content="<final>")],
|
[mock_llm_chunk(content="<final>")],
|
||||||
|
|
@ -358,32 +358,24 @@ async def test_auto_compact_emits_summary_recount_and_next_turn_metadata() -> No
|
||||||
|
|
||||||
[_ async for _ in agent.act("Hello")]
|
[_ async for _ in agent.act("Hello")]
|
||||||
|
|
||||||
assert len(backend.requests_metadata) == 3
|
assert len(backend.requests_metadata) == 2
|
||||||
assert len(backend.requests_extra_headers) == 3
|
assert len(backend.requests_extra_headers) == 2
|
||||||
compact_metadata = backend.requests_metadata[0]
|
compact_metadata = backend.requests_metadata[0]
|
||||||
recount_metadata = backend.requests_metadata[1]
|
user_turn_metadata = backend.requests_metadata[1]
|
||||||
user_turn_metadata = backend.requests_metadata[2]
|
|
||||||
assert compact_metadata is not None
|
assert compact_metadata is not None
|
||||||
assert recount_metadata is not None
|
|
||||||
assert user_turn_metadata is not None
|
assert user_turn_metadata is not None
|
||||||
assert compact_metadata["call_type"] == "secondary_call"
|
assert compact_metadata["call_type"] == "secondary_call"
|
||||||
assert compact_metadata["session_id"] == original_session_id
|
assert compact_metadata["session_id"] == original_session_id
|
||||||
assert "parent_session_id" not in compact_metadata
|
assert "parent_session_id" not in compact_metadata
|
||||||
assert recount_metadata["call_type"] == "secondary_call"
|
|
||||||
assert recount_metadata["session_id"] == agent.session_id
|
|
||||||
assert recount_metadata["parent_session_id"] == original_session_id
|
|
||||||
assert user_turn_metadata["call_type"] == "main_call"
|
assert user_turn_metadata["call_type"] == "main_call"
|
||||||
assert user_turn_metadata["session_id"] == agent.session_id
|
assert user_turn_metadata["session_id"] == agent.session_id
|
||||||
assert user_turn_metadata["parent_session_id"] == original_session_id
|
assert user_turn_metadata["parent_session_id"] == original_session_id
|
||||||
|
|
||||||
compact_headers = backend.requests_extra_headers[0]
|
compact_headers = backend.requests_extra_headers[0]
|
||||||
recount_headers = backend.requests_extra_headers[1]
|
user_turn_headers = backend.requests_extra_headers[1]
|
||||||
user_turn_headers = backend.requests_extra_headers[2]
|
|
||||||
assert compact_headers is not None
|
assert compact_headers is not None
|
||||||
assert recount_headers is not None
|
|
||||||
assert user_turn_headers is not None
|
assert user_turn_headers is not None
|
||||||
assert compact_headers["x-affinity"] == original_session_id
|
assert compact_headers["x-affinity"] == original_session_id
|
||||||
assert recount_headers["x-affinity"] == agent.session_id
|
|
||||||
assert user_turn_headers["x-affinity"] == agent.session_id
|
assert user_turn_headers["x-affinity"] == agent.session_id
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -526,13 +526,10 @@ class TestAutoCompactIntegration:
|
||||||
assert isinstance(events[3], AssistantEvent)
|
assert isinstance(events[3], AssistantEvent)
|
||||||
|
|
||||||
start: CompactStartEvent = events[1]
|
start: CompactStartEvent = events[1]
|
||||||
end: CompactEndEvent = events[2]
|
|
||||||
final: AssistantEvent = events[3]
|
final: AssistantEvent = events[3]
|
||||||
|
|
||||||
assert start.current_context_tokens == 2
|
assert start.current_context_tokens == 2
|
||||||
assert start.threshold == 1
|
assert start.threshold == 1
|
||||||
assert end.old_context_tokens == 2
|
|
||||||
assert end.new_context_tokens >= 1
|
|
||||||
assert final.content == "<final>"
|
assert final.content == "<final>"
|
||||||
|
|
||||||
roles = [r for r, _ in observed]
|
roles = [r for r, _ in observed]
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,54 @@ class TestACloseCancelsExperimentsTask:
|
||||||
assert not task.cancelled()
|
assert not task.cancelled()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCycleAgentDuringInit:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shift_tab_during_experiments_init_does_not_crash(self) -> None:
|
||||||
|
"""Regression: shift+tab during init crashed with
|
||||||
|
RuntimeError("await wasn't used with future").
|
||||||
|
|
||||||
|
_cycle_agent ran switch_agent via asyncio.run() in a thread worker,
|
||||||
|
creating a second event loop. wait_until_ready then tried to await
|
||||||
|
_experiments_task (owned by the main Textual loop) from that new loop.
|
||||||
|
|
||||||
|
The fix uses asyncio.run_coroutine_threadsafe() to schedule on the
|
||||||
|
main loop instead. This test presses shift+tab while experiments
|
||||||
|
are still initializing and asserts the worker completes without error.
|
||||||
|
"""
|
||||||
|
from tests.conftest import build_test_vibe_app
|
||||||
|
|
||||||
|
gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def slow_init() -> None:
|
||||||
|
await gate.wait()
|
||||||
|
|
||||||
|
agent_loop = build_test_agent_loop()
|
||||||
|
app = build_test_vibe_app(agent_loop=agent_loop)
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
with patch.object(
|
||||||
|
agent_loop, "initialize_experiments", side_effect=slow_init
|
||||||
|
):
|
||||||
|
agent_loop._experiments_task = None # reset so start_ re-fires
|
||||||
|
agent_loop.start_initialize_experiments()
|
||||||
|
|
||||||
|
assert agent_loop._experiments_task is not None
|
||||||
|
assert not agent_loop._experiments_task.done()
|
||||||
|
|
||||||
|
# Press shift+tab while experiments are still running.
|
||||||
|
await pilot.press("shift+tab")
|
||||||
|
await pilot.pause(0.05)
|
||||||
|
|
||||||
|
# Unblock experiments so switch_agent can complete.
|
||||||
|
gate.set()
|
||||||
|
|
||||||
|
# wait_for_complete raises WorkerFailed if the thread worker
|
||||||
|
# crashed — which is exactly what happened before the fix.
|
||||||
|
await pilot.app.workers.wait_for_complete()
|
||||||
|
|
||||||
|
assert agent_loop.agent_profile.name == "plan"
|
||||||
|
|
||||||
|
|
||||||
class TestActGatesOnExperiments:
|
class TestActGatesOnExperiments:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_act_awaits_experiments_before_llm_call(self) -> None:
|
async def test_act_awaits_experiments_before_llm_call(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -20,9 +22,12 @@ from vibe.core.tools.mcp import (
|
||||||
_mcp_stderr_capture,
|
_mcp_stderr_capture,
|
||||||
_parse_call_result,
|
_parse_call_result,
|
||||||
_stderr_logger_thread,
|
_stderr_logger_thread,
|
||||||
|
call_tool_http,
|
||||||
call_tool_stdio,
|
call_tool_stdio,
|
||||||
create_mcp_http_proxy_tool_class,
|
create_mcp_http_proxy_tool_class,
|
||||||
create_mcp_stdio_proxy_tool_class,
|
create_mcp_stdio_proxy_tool_class,
|
||||||
|
create_vibe_mcp_http_client,
|
||||||
|
list_tools_http,
|
||||||
list_tools_stdio,
|
list_tools_stdio,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -133,6 +138,130 @@ class TestParseCallResult:
|
||||||
assert result.text == "line1\nline2"
|
assert result.text == "line1\nline2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPHttpClient:
|
||||||
|
def test_create_vibe_mcp_http_client_uses_vibe_ssl_context(self):
|
||||||
|
headers = {"Authorization": "Bearer token"}
|
||||||
|
ssl_context = object()
|
||||||
|
fake_client = object()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"vibe.core.tools.mcp.tools.build_ssl_context", return_value=ssl_context
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"vibe.core.tools.mcp.tools.httpx.AsyncClient", return_value=fake_client
|
||||||
|
) as async_client,
|
||||||
|
):
|
||||||
|
client = create_vibe_mcp_http_client(headers)
|
||||||
|
|
||||||
|
assert client is fake_client
|
||||||
|
kwargs = async_client.call_args.kwargs
|
||||||
|
assert kwargs["follow_redirects"] is True
|
||||||
|
assert kwargs["headers"] == headers
|
||||||
|
assert kwargs["verify"] is ssl_context
|
||||||
|
assert kwargs["timeout"].connect == 30.0
|
||||||
|
assert kwargs["timeout"].read == 300.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tools_http_uses_vibe_mcp_http_client(self):
|
||||||
|
fake_client = _FakeHttpClient()
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def fake_stream(url: str, *, http_client: Any):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["http_client"] = http_client
|
||||||
|
yield object(), object(), lambda: None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
|
||||||
|
return_value=fake_client,
|
||||||
|
) as create_client,
|
||||||
|
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
|
||||||
|
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
|
||||||
|
):
|
||||||
|
tools = await list_tools_http(
|
||||||
|
"https://mcp.example.com",
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
startup_timeout_sec=42.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_client.assert_called_once_with({"Authorization": "Bearer token"})
|
||||||
|
assert fake_client.entered is True
|
||||||
|
assert fake_client.closed is True
|
||||||
|
assert captured["url"] == "https://mcp.example.com"
|
||||||
|
assert captured["http_client"] is fake_client
|
||||||
|
assert [tool.name for tool in tools] == ["remote_tool"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_tool_http_uses_vibe_mcp_http_client(self):
|
||||||
|
fake_client = _FakeHttpClient()
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def fake_stream(url: str, *, http_client: Any):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["http_client"] = http_client
|
||||||
|
yield object(), object(), lambda: None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"vibe.core.tools.mcp.tools.create_vibe_mcp_http_client",
|
||||||
|
return_value=fake_client,
|
||||||
|
) as create_client,
|
||||||
|
patch("vibe.core.tools.mcp.tools.streamable_http_client", fake_stream),
|
||||||
|
patch("vibe.core.tools.mcp.tools.ClientSession", _FakeMCPClientSession),
|
||||||
|
):
|
||||||
|
result = await call_tool_http(
|
||||||
|
"https://mcp.example.com",
|
||||||
|
"remote_tool",
|
||||||
|
{"query": "hello"},
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
startup_timeout_sec=42.0,
|
||||||
|
tool_timeout_sec=12.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_client.assert_called_once_with({"Authorization": "Bearer token"})
|
||||||
|
assert fake_client.entered is True
|
||||||
|
assert fake_client.closed is True
|
||||||
|
assert captured["url"] == "https://mcp.example.com"
|
||||||
|
assert captured["http_client"] is fake_client
|
||||||
|
assert result.structured == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHttpClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.entered = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeHttpClient:
|
||||||
|
self.entered = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: Any) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMCPClientSession:
|
||||||
|
def __init__(self, *_: Any, **__: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeMCPClientSession:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def list_tools(self) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(tools=[{"name": "remote_tool"}])
|
||||||
|
|
||||||
|
async def call_tool(self, *_: Any, **__: Any) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(structuredContent={"ok": True}, content=None)
|
||||||
|
|
||||||
|
|
||||||
class TestMCPStderrCapture:
|
class TestMCPStderrCapture:
|
||||||
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""
|
"""Tests for _mcp_stderr_capture and _stderr_logger_thread."""
|
||||||
|
|
||||||
|
|
|
||||||
116
uv.lock
generated
116
uv.lock
generated
|
|
@ -3,7 +3,7 @@ revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
exclude-newer = "2026-05-20T19:55:04.890389Z"
|
exclude-newer = "2026-05-22T14:05:58.077106Z"
|
||||||
exclude-newer-span = "P7D"
|
exclude-newer-span = "P7D"
|
||||||
|
|
||||||
[options.exclude-newer-package]
|
[options.exclude-newer-package]
|
||||||
|
|
@ -775,7 +775,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.27.0"
|
version = "1.27.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
|
|
@ -793,9 +793,9 @@ dependencies = [
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -821,7 +821,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.12.1"
|
version = "2.13.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
@ -908,6 +908,7 @@ dependencies = [
|
||||||
{ name = "tomli-w" },
|
{ name = "tomli-w" },
|
||||||
{ name = "tree-sitter" },
|
{ name = "tree-sitter" },
|
||||||
{ name = "tree-sitter-bash" },
|
{ name = "tree-sitter-bash" },
|
||||||
|
{ name = "truststore" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uc-micro-py" },
|
{ name = "uc-micro-py" },
|
||||||
|
|
@ -922,14 +923,65 @@ dependencies = [
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
build = [
|
build = [
|
||||||
{ name = "altgraph" },
|
{ name = "altgraph" },
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "docutils" },
|
||||||
|
{ name = "execnet" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "h11" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "id" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||||
|
{ name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||||
|
{ name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||||
|
{ name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||||
|
{ name = "linkify-it-py" },
|
||||||
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
{ name = "mdurl" },
|
||||||
|
{ name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||||
|
{ name = "nh3" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'" },
|
||||||
|
{ name = "pygments" },
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
{ name = "pyinstaller-hooks-contrib" },
|
{ name = "pyinstaller-hooks-contrib" },
|
||||||
|
{ name = "pyinstrument" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "readme-renderer" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "requests-toolbelt" },
|
||||||
|
{ name = "rfc3986" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'" },
|
||||||
{ name = "setuptools" },
|
{ name = "setuptools" },
|
||||||
|
{ name = "syrupy" },
|
||||||
|
{ name = "textual" },
|
||||||
{ name = "truststore" },
|
{ name = "truststore" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "uc-micro-py" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "debugpy" },
|
{ name = "debugpy" },
|
||||||
|
|
@ -988,7 +1040,7 @@ requires-dist = [
|
||||||
{ name = "linkify-it-py", specifier = "==2.1.0" },
|
{ name = "linkify-it-py", specifier = "==2.1.0" },
|
||||||
{ name = "markdown-it-py", specifier = "==4.0.0" },
|
{ name = "markdown-it-py", specifier = "==4.0.0" },
|
||||||
{ name = "markdownify", specifier = "==1.2.2" },
|
{ name = "markdownify", specifier = "==1.2.2" },
|
||||||
{ name = "mcp", specifier = "==1.27.0" },
|
{ name = "mcp", specifier = "==1.27.1" },
|
||||||
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
|
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
|
||||||
{ name = "mdurl", specifier = "==0.1.2" },
|
{ name = "mdurl", specifier = "==0.1.2" },
|
||||||
{ name = "mistralai", specifier = "==2.4.4" },
|
{ name = "mistralai", specifier = "==2.4.4" },
|
||||||
|
|
@ -1035,6 +1087,7 @@ requires-dist = [
|
||||||
{ name = "tomli-w", specifier = "==1.2.0" },
|
{ name = "tomli-w", specifier = "==1.2.0" },
|
||||||
{ name = "tree-sitter", specifier = "==0.25.2" },
|
{ name = "tree-sitter", specifier = "==0.25.2" },
|
||||||
{ name = "tree-sitter-bash", specifier = "==0.25.1" },
|
{ name = "tree-sitter-bash", specifier = "==0.25.1" },
|
||||||
|
{ name = "truststore", specifier = "==0.10.4" },
|
||||||
{ name = "typing-extensions", specifier = "==4.15.0" },
|
{ name = "typing-extensions", specifier = "==4.15.0" },
|
||||||
{ name = "typing-inspection", specifier = "==0.4.2" },
|
{ name = "typing-inspection", specifier = "==0.4.2" },
|
||||||
{ name = "uc-micro-py", specifier = "==2.0.0" },
|
{ name = "uc-micro-py", specifier = "==2.0.0" },
|
||||||
|
|
@ -1049,14 +1102,65 @@ requires-dist = [
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
build = [
|
build = [
|
||||||
{ name = "altgraph", specifier = "==0.17.5" },
|
{ name = "altgraph", specifier = "==0.17.5" },
|
||||||
|
{ name = "anyio", specifier = "==4.13.0" },
|
||||||
|
{ name = "certifi", specifier = "==2026.4.22" },
|
||||||
|
{ name = "cffi", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==2.0.0" },
|
||||||
|
{ name = "cfgv", specifier = "==3.5.0" },
|
||||||
|
{ name = "charset-normalizer", specifier = "==3.4.7" },
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'", specifier = "==0.4.6" },
|
||||||
|
{ name = "cryptography", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==47.0.0" },
|
||||||
|
{ name = "distlib", specifier = "==0.4.0" },
|
||||||
|
{ name = "docutils", specifier = "==0.22.4" },
|
||||||
|
{ name = "execnet", specifier = "==2.1.2" },
|
||||||
|
{ name = "filelock", specifier = "==3.29.0" },
|
||||||
|
{ name = "h11", specifier = "==0.16.0" },
|
||||||
|
{ name = "httpcore", specifier = "==1.0.9" },
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "id", specifier = "==1.6.1" },
|
||||||
|
{ name = "identify", specifier = "==2.6.19" },
|
||||||
|
{ name = "idna", specifier = "==3.13" },
|
||||||
|
{ name = "iniconfig", specifier = "==2.3.0" },
|
||||||
|
{ name = "jaraco-classes", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==3.4.0" },
|
||||||
|
{ name = "jaraco-context", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==6.1.2" },
|
||||||
|
{ name = "jaraco-functools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==4.4.0" },
|
||||||
|
{ name = "jeepney", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==0.9.0" },
|
||||||
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
|
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==25.7.0" },
|
||||||
|
{ name = "linkify-it-py", specifier = "==2.1.0" },
|
||||||
{ name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" },
|
{ name = "macholib", marker = "sys_platform == 'darwin'", specifier = "==1.16.4" },
|
||||||
|
{ name = "markdown-it-py", specifier = "==4.0.0" },
|
||||||
|
{ name = "markupsafe", specifier = "==3.0.3" },
|
||||||
|
{ name = "mdit-py-plugins", specifier = "==0.5.0" },
|
||||||
|
{ name = "mdurl", specifier = "==0.1.2" },
|
||||||
|
{ name = "more-itertools", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'", specifier = "==11.0.2" },
|
||||||
|
{ name = "nh3", specifier = "==0.3.5" },
|
||||||
|
{ name = "nodeenv", specifier = "==1.10.0" },
|
||||||
{ name = "packaging", specifier = "==26.2" },
|
{ name = "packaging", specifier = "==26.2" },
|
||||||
{ name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" },
|
{ name = "pefile", marker = "sys_platform == 'win32'", specifier = "==2024.8.26" },
|
||||||
|
{ name = "platformdirs", specifier = "==4.9.6" },
|
||||||
|
{ name = "pluggy", specifier = "==1.6.0" },
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy' and platform_machine != 'ppc64le' and platform_machine != 's390x' and platform_python_implementation != 'PyPy' and sys_platform == 'linux'", specifier = "==3.0" },
|
||||||
|
{ name = "pygments", specifier = "==2.20.0" },
|
||||||
{ name = "pyinstaller", specifier = "==6.20.0" },
|
{ name = "pyinstaller", specifier = "==6.20.0" },
|
||||||
{ name = "pyinstaller-hooks-contrib", specifier = "==2026.4" },
|
{ name = "pyinstaller-hooks-contrib", specifier = "==2026.4" },
|
||||||
|
{ name = "pyinstrument", specifier = "==5.1.2" },
|
||||||
|
{ name = "python-discovery", specifier = "==1.2.2" },
|
||||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" },
|
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'", specifier = "==0.2.3" },
|
||||||
|
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||||
|
{ name = "readme-renderer", specifier = "==44.0" },
|
||||||
|
{ name = "requests", specifier = "==2.33.1" },
|
||||||
|
{ name = "requests-toolbelt", specifier = "==1.0.0" },
|
||||||
|
{ name = "rfc3986", specifier = "==2.0.0" },
|
||||||
|
{ name = "rich", specifier = "==15.0.0" },
|
||||||
|
{ name = "secretstorage", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform == 'linux'", specifier = "==3.5.0" },
|
||||||
{ name = "setuptools", specifier = "==82.0.1" },
|
{ name = "setuptools", specifier = "==82.0.1" },
|
||||||
|
{ name = "syrupy", specifier = "==4.8.0" },
|
||||||
|
{ name = "textual", specifier = "==8.2.7" },
|
||||||
{ name = "truststore", specifier = "==0.10.4" },
|
{ name = "truststore", specifier = "==0.10.4" },
|
||||||
|
{ name = "typing-extensions", specifier = "==4.15.0" },
|
||||||
|
{ name = "uc-micro-py", specifier = "==2.0.0" },
|
||||||
|
{ name = "urllib3", specifier = "==2.6.3" },
|
||||||
|
{ name = "virtualenv", specifier = "==21.2.4" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "debugpy", specifier = ">=1.8.19" },
|
{ name = "debugpy", specifier = ">=1.8.19" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.12.1"
|
__version__ = "2.13.0"
|
||||||
|
|
|
||||||
|
|
@ -1516,6 +1516,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
)
|
)
|
||||||
return provider, auth_state
|
return provider, auth_state
|
||||||
|
|
||||||
|
def _process_env_value_before_dotenv_load(
|
||||||
|
self, provider: ProviderConfig
|
||||||
|
) -> str | None:
|
||||||
|
if not provider.api_key_env_var:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._environ_before_dotenv_load.get(provider.api_key_env_var)
|
||||||
|
|
||||||
def _handle_auth_status(self) -> dict[str, Any]:
|
def _handle_auth_status(self) -> dict[str, Any]:
|
||||||
_, auth_state = self._assess_current_auth_state()
|
_, auth_state = self._assess_current_auth_state()
|
||||||
return _auth_status_response_from_auth_state(auth_state).model_dump(
|
return _auth_status_response_from_auth_state(auth_state).model_dump(
|
||||||
|
|
@ -1531,6 +1539,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._remove_api_key(provider)
|
self._remove_api_key(provider)
|
||||||
|
if (
|
||||||
|
auth_state.kind
|
||||||
|
== AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV
|
||||||
|
and auth_state.env_key
|
||||||
|
):
|
||||||
|
process_env_value = self._process_env_value_before_dotenv_load(provider)
|
||||||
|
if process_env_value:
|
||||||
|
os.environ[auth_state.env_key] = process_env_value
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise InternalError(f"Failed to sign out: {exc}") from exc
|
raise InternalError(f"Failed to sign out: {exc}") from exc
|
||||||
|
|
||||||
|
|
@ -1650,11 +1666,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.agent_loop.compact(extra_instructions=cmd_args.strip())
|
await session.agent_loop.compact(extra_instructions=cmd_args.strip())
|
||||||
new_tokens = session.agent_loop.stats.context_tokens
|
|
||||||
|
|
||||||
end_event = CompactEndEvent(
|
end_event = CompactEndEvent(
|
||||||
old_context_tokens=old_tokens or 0,
|
|
||||||
new_context_tokens=new_tokens or 0,
|
|
||||||
summary_length=0,
|
summary_length=0,
|
||||||
old_session_id=old_session_id,
|
old_session_id=old_session_id,
|
||||||
new_session_id=session.agent_loop.session_id,
|
new_session_id=session.agent_loop.session_id,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ from vibe.core.config._settings import THINKING_LEVELS, ThinkingLevel
|
||||||
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
from vibe.core.proxy_setup import SUPPORTED_PROXY_VARS, get_current_proxy_settings
|
||||||
from vibe.core.tools.permissions import RequiredPermission
|
from vibe.core.tools.permissions import RequiredPermission
|
||||||
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
from vibe.core.types import CompactEndEvent, CompactStartEvent, LLMMessage
|
||||||
from vibe.core.utils import compact_reduction_display
|
from vibe.core.utils import compact_complete_display
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from vibe.core.config import ModelConfig
|
from vibe.core.config import ModelConfig
|
||||||
|
|
@ -230,9 +230,7 @@ def create_compact_end_session_update(event: CompactEndEvent) -> ToolCallProgres
|
||||||
content=TextContentBlock(
|
content=TextContentBlock(
|
||||||
type="text",
|
type="text",
|
||||||
text=(
|
text=(
|
||||||
compact_reduction_display(
|
compact_complete_display(
|
||||||
event.old_context_tokens,
|
|
||||||
event.new_context_tokens,
|
|
||||||
old_session_id=event.old_session_id,
|
old_session_id=event.old_session_id,
|
||||||
new_session_id=event.new_session_id,
|
new_session_id=event.new_session_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||||
WhoAmIResponse,
|
WhoAmIResponse,
|
||||||
)
|
)
|
||||||
from vibe.core.config import (
|
from vibe.core.config import (
|
||||||
DEFAULT_CONSOLE_BASE_URL,
|
|
||||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||||
|
DEFAULT_VIBE_BASE_URL,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
)
|
)
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
|
|
@ -97,20 +97,18 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def plan_offer_cta(
|
def plan_offer_cta(
|
||||||
payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
|
payload: PlanInfo | None, *, vibe_base_url: str = DEFAULT_VIBE_BASE_URL
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
if not payload:
|
if not payload:
|
||||||
return
|
return
|
||||||
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
|
vibe_api_key_url = f"{vibe_base_url.rstrip('/')}/code/extensions?focus=key"
|
||||||
switch_to_pro_key_url = console_cli_url
|
|
||||||
upgrade_url = console_cli_url
|
|
||||||
if payload.prompt_switching_to_pro_plan:
|
if payload.prompt_switching_to_pro_plan:
|
||||||
return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
|
return f"### Switch to your [Vibe Pro API key]({vibe_api_key_url})"
|
||||||
if (
|
if (
|
||||||
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||||
or payload.is_free_mistral_code_plan()
|
or payload.is_free_mistral_code_plan()
|
||||||
):
|
):
|
||||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
|
return f"### Unlock more with Vibe - [Upgrade to Vibe Pro]({vibe_api_key_url})"
|
||||||
|
|
||||||
|
|
||||||
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
||||||
|
|
|
||||||
|
|
@ -2183,32 +2183,26 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
if not self.event_handler:
|
if not self.event_handler:
|
||||||
return
|
return
|
||||||
|
|
||||||
old_tokens = self.agent_loop.stats.context_tokens
|
|
||||||
old_session_id = self.agent_loop.session_id
|
old_session_id = self.agent_loop.session_id
|
||||||
compact_msg = CompactMessage()
|
compact_msg = CompactMessage()
|
||||||
self.event_handler.current_compact = compact_msg
|
self.event_handler.current_compact = compact_msg
|
||||||
await self._mount_and_scroll(compact_msg)
|
await self._mount_and_scroll(compact_msg)
|
||||||
|
|
||||||
self._agent_task = asyncio.create_task(
|
self._agent_task = asyncio.create_task(
|
||||||
self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
|
self._run_compact(compact_msg, old_session_id, cmd_args.strip())
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _run_compact(
|
async def _run_compact(
|
||||||
self,
|
self,
|
||||||
compact_msg: CompactMessage,
|
compact_msg: CompactMessage,
|
||||||
old_tokens: int,
|
|
||||||
old_session_id: str,
|
old_session_id: str,
|
||||||
extra_instructions: str = "",
|
extra_instructions: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self._agent_running = True
|
self._agent_running = True
|
||||||
try:
|
try:
|
||||||
await self.agent_loop.compact(extra_instructions=extra_instructions)
|
await self.agent_loop.compact(extra_instructions=extra_instructions)
|
||||||
new_tokens = self.agent_loop.stats.context_tokens
|
|
||||||
compact_msg.set_complete(
|
compact_msg.set_complete(
|
||||||
old_tokens=old_tokens,
|
old_session_id=old_session_id, new_session_id=self.agent_loop.session_id
|
||||||
new_tokens=new_tokens,
|
|
||||||
old_session_id=old_session_id,
|
|
||||||
new_session_id=self.agent_loop.session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|
@ -2887,13 +2881,18 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
if self._chat_input_container:
|
if self._chat_input_container:
|
||||||
self._chat_input_container.switching_mode = True
|
self._chat_input_container.switching_mode = True
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def schedule_switch() -> None:
|
def schedule_switch() -> None:
|
||||||
self._switch_agent_generation += 1
|
self._switch_agent_generation += 1
|
||||||
my_gen = self._switch_agent_generation
|
my_gen = self._switch_agent_generation
|
||||||
|
|
||||||
def switch_agent_sync() -> None:
|
def switch_agent_sync() -> None:
|
||||||
try:
|
try:
|
||||||
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self.agent_loop.switch_agent(new_profile.name), loop
|
||||||
|
)
|
||||||
|
future.result()
|
||||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -3031,7 +3030,7 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
if content is not None:
|
if content is not None:
|
||||||
body = content
|
body = content
|
||||||
plan_offer = plan_offer_cta(
|
plan_offer = plan_offer_cta(
|
||||||
self._plan_info, console_base_url=self.config.console_base_url
|
self._plan_info, vibe_base_url=self.config.vibe_base_url
|
||||||
)
|
)
|
||||||
if plan_offer is not None:
|
if plan_offer is not None:
|
||||||
body = f"{body}\n\n{plan_offer}"
|
body = f"{body}\n\n{plan_offer}"
|
||||||
|
|
@ -3059,12 +3058,10 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
if not self._show_vscode_extension_promo:
|
if not self._show_vscode_extension_promo:
|
||||||
return
|
return
|
||||||
promo_message = VscodeExtensionPromoMessage()
|
promo_message = VscodeExtensionPromoMessage()
|
||||||
if self._history_widget_indices:
|
|
||||||
promo_message.add_class("after-history")
|
|
||||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||||
should_anchor = chat.is_at_bottom
|
should_anchor = chat.is_at_bottom
|
||||||
await chat.mount(promo_message, after=messages_area)
|
await chat.mount(promo_message, before=messages_area)
|
||||||
if should_anchor:
|
if should_anchor:
|
||||||
chat.anchor()
|
chat.anchor()
|
||||||
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)
|
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)
|
||||||
|
|
|
||||||
|
|
@ -1281,10 +1281,6 @@ NarratorStatus {
|
||||||
link-color-hover: $mistral_orange;
|
link-color-hover: $mistral_orange;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vscode-extension-promo-message.after-history {
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sessionpicker-app {
|
#sessionpicker-app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
|
||||||
|
|
@ -229,10 +229,7 @@ class EventHandler:
|
||||||
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
|
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
|
||||||
if self.current_compact:
|
if self.current_compact:
|
||||||
self.current_compact.set_complete(
|
self.current_compact.set_complete(
|
||||||
old_tokens=event.old_context_tokens,
|
old_session_id=event.old_session_id, new_session_id=event.new_session_id
|
||||||
new_tokens=event.new_context_tokens,
|
|
||||||
old_session_id=event.old_session_id,
|
|
||||||
new_session_id=event.new_session_id,
|
|
||||||
)
|
)
|
||||||
self.current_compact = None
|
self.current_compact = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
|
|
||||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||||
from vibe.core.utils import compact_reduction_display
|
from vibe.core.utils import compact_complete_display
|
||||||
|
|
||||||
|
|
||||||
class CompactMessage(StatusMessage):
|
class CompactMessage(StatusMessage):
|
||||||
|
|
@ -15,8 +15,6 @@ class CompactMessage(StatusMessage):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_class("compact-message")
|
self.add_class("compact-message")
|
||||||
self.old_tokens: int | None = None
|
|
||||||
self.new_tokens: int | None = None
|
|
||||||
self.old_session_id: str | None = None
|
self.old_session_id: str | None = None
|
||||||
self.new_session_id: str | None = None
|
self.new_session_id: str | None = None
|
||||||
self.error_message: str | None = None
|
self.error_message: str | None = None
|
||||||
|
|
@ -28,23 +26,13 @@ class CompactMessage(StatusMessage):
|
||||||
if self.error_message:
|
if self.error_message:
|
||||||
return f"Error: {self.error_message}"
|
return f"Error: {self.error_message}"
|
||||||
|
|
||||||
return compact_reduction_display(
|
return compact_complete_display(
|
||||||
self.old_tokens,
|
old_session_id=self.old_session_id, new_session_id=self.new_session_id
|
||||||
self.new_tokens,
|
|
||||||
old_session_id=self.old_session_id,
|
|
||||||
new_session_id=self.new_session_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_complete(
|
def set_complete(
|
||||||
self,
|
self, *, old_session_id: str | None = None, new_session_id: str | None = None
|
||||||
old_tokens: int | None = None,
|
|
||||||
new_tokens: int | None = None,
|
|
||||||
*,
|
|
||||||
old_session_id: str | None = None,
|
|
||||||
new_session_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.old_tokens = old_tokens
|
|
||||||
self.new_tokens = new_tokens
|
|
||||||
self.old_session_id = old_session_id
|
self.old_session_id = old_session_id
|
||||||
self.new_session_id = new_session_id
|
self.new_session_id = new_session_id
|
||||||
self.stop_spinning(success=True)
|
self.stop_spinning(success=True)
|
||||||
|
|
|
||||||
|
|
@ -810,7 +810,6 @@ class AgentLoop: # noqa: PLR0904
|
||||||
)
|
)
|
||||||
|
|
||||||
compact_status: Literal["success", "failure", "cancelled"] = "success"
|
compact_status: Literal["success", "failure", "cancelled"] = "success"
|
||||||
new_tokens = self.stats.context_tokens
|
|
||||||
try:
|
try:
|
||||||
summary = await self.compact()
|
summary = await self.compact()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|
@ -820,10 +819,8 @@ class AgentLoop: # noqa: PLR0904
|
||||||
compact_status = "failure"
|
compact_status = "failure"
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
new_tokens = self.stats.context_tokens
|
|
||||||
self.telemetry_client.send_auto_compact_triggered(
|
self.telemetry_client.send_auto_compact_triggered(
|
||||||
nb_context_tokens_before=old_tokens,
|
nb_context_tokens_before=old_tokens,
|
||||||
nb_context_tokens_after=new_tokens,
|
|
||||||
auto_compact_threshold=threshold,
|
auto_compact_threshold=threshold,
|
||||||
status=compact_status,
|
status=compact_status,
|
||||||
session_id=old_session_id,
|
session_id=old_session_id,
|
||||||
|
|
@ -832,8 +829,6 @@ class AgentLoop: # noqa: PLR0904
|
||||||
|
|
||||||
yield CompactEndEvent(
|
yield CompactEndEvent(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
old_context_tokens=old_tokens,
|
|
||||||
new_context_tokens=new_tokens,
|
|
||||||
summary_length=len(summary),
|
summary_length=len(summary),
|
||||||
old_session_id=old_session_id,
|
old_session_id=old_session_id,
|
||||||
new_session_id=self.session_id,
|
new_session_id=self.session_id,
|
||||||
|
|
@ -1724,21 +1719,16 @@ class AgentLoop: # noqa: PLR0904
|
||||||
|
|
||||||
system_message = self.messages[0]
|
system_message = self.messages[0]
|
||||||
wrapped_summary = f"{summary_prefix}\n{summary_content}"
|
wrapped_summary = f"{summary_prefix}\n{summary_content}"
|
||||||
summary_message = LLMMessage(role=Role.user, content=wrapped_summary)
|
summary_message = LLMMessage(
|
||||||
|
role=Role.user, content=wrapped_summary, injected=True
|
||||||
|
)
|
||||||
self.messages.reset([system_message, *prior_user_messages, summary_message])
|
self.messages.reset([system_message, *prior_user_messages, summary_message])
|
||||||
|
|
||||||
active_model = self.config.get_active_model()
|
|
||||||
await self._reset_session()
|
await self._reset_session()
|
||||||
|
|
||||||
actual_context_tokens = await self.backend.count_tokens(
|
# Context size is unknown without an API call; reset to 0. The next
|
||||||
model=active_model,
|
# LLM turn recomputes it accurately from real usage (_update_stats).
|
||||||
messages=self.messages,
|
self.stats.context_tokens = 0
|
||||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
|
||||||
extra_headers=self._get_extra_headers(),
|
|
||||||
metadata=self._build_backend_metadata().model_dump(exclude_none=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stats.context_tokens = actual_context_tokens
|
|
||||||
await self.session_logger.save_interaction(
|
await self.session_logger.save_interaction(
|
||||||
self.messages,
|
self.messages,
|
||||||
self.stats,
|
self.stats,
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,13 @@ def collect_prior_user_messages(
|
||||||
content = m.content or ""
|
content = m.content or ""
|
||||||
cost = approx_token_count(content)
|
cost = approx_token_count(content)
|
||||||
if cost <= remaining:
|
if cost <= remaining:
|
||||||
selected.append(LLMMessage(role=Role.user, content=content))
|
selected.append(LLMMessage(role=Role.user, content=content, injected=True))
|
||||||
remaining -= cost
|
remaining -= cost
|
||||||
else:
|
else:
|
||||||
truncated = truncate_middle_to_tokens(content, remaining)
|
truncated = truncate_middle_to_tokens(content, remaining)
|
||||||
selected.append(LLMMessage(role=Role.user, content=truncated))
|
selected.append(
|
||||||
|
LLMMessage(role=Role.user, content=truncated, injected=True)
|
||||||
|
)
|
||||||
remaining = 0
|
remaining = 0
|
||||||
|
|
||||||
selected.reverse()
|
selected.reverse()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from vibe.core.config._settings import (
|
||||||
DEFAULT_TRANSCRIBE_PROVIDERS,
|
DEFAULT_TRANSCRIBE_PROVIDERS,
|
||||||
DEFAULT_TTS_MODELS,
|
DEFAULT_TTS_MODELS,
|
||||||
DEFAULT_TTS_PROVIDERS,
|
DEFAULT_TTS_PROVIDERS,
|
||||||
|
DEFAULT_VIBE_BASE_URL,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
ConnectorConfig,
|
ConnectorConfig,
|
||||||
ExperimentsConfig,
|
ExperimentsConfig,
|
||||||
|
|
@ -76,6 +77,7 @@ __all__ = [
|
||||||
"DEFAULT_TRANSCRIBE_PROVIDERS",
|
"DEFAULT_TRANSCRIBE_PROVIDERS",
|
||||||
"DEFAULT_TTS_MODELS",
|
"DEFAULT_TTS_MODELS",
|
||||||
"DEFAULT_TTS_PROVIDERS",
|
"DEFAULT_TTS_PROVIDERS",
|
||||||
|
"DEFAULT_VIBE_BASE_URL",
|
||||||
"THINKING_LEVELS",
|
"THINKING_LEVELS",
|
||||||
"AppendToList",
|
"AppendToList",
|
||||||
"ConfigDefinitionError",
|
"ConfigDefinitionError",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ from vibe.core.logger import logger
|
||||||
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
|
||||||
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
|
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
from vibe.core.utils import get_server_url_from_api_base
|
from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
|
||||||
|
|
||||||
|
|
||||||
def _strip_bash_pattern_wildcard(pattern: str) -> str:
|
def _strip_bash_pattern_wildcard(pattern: str) -> str:
|
||||||
|
|
@ -165,6 +165,7 @@ DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||||
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
|
DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL = "https://console.mistral.ai"
|
||||||
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
|
DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL = "https://console.mistral.ai/api"
|
||||||
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
|
DEFAULT_CONSOLE_BASE_URL = "https://console.mistral.ai"
|
||||||
|
DEFAULT_VIBE_BASE_URL = "https://chat.mistral.ai"
|
||||||
|
|
||||||
|
|
||||||
class ProviderConfig(BaseModel):
|
class ProviderConfig(BaseModel):
|
||||||
|
|
@ -520,6 +521,7 @@ class VibeConfig(BaseSettings):
|
||||||
enable_update_checks: bool = True
|
enable_update_checks: bool = True
|
||||||
enable_auto_update: bool = True
|
enable_auto_update: bool = True
|
||||||
enable_notifications: bool = True
|
enable_notifications: bool = True
|
||||||
|
enable_system_trust_store: bool = False
|
||||||
api_timeout: float = 720.0
|
api_timeout: float = 720.0
|
||||||
auto_compact_threshold: int = 200_000
|
auto_compact_threshold: int = 200_000
|
||||||
|
|
||||||
|
|
@ -537,6 +539,7 @@ class VibeConfig(BaseSettings):
|
||||||
otel_endpoint: str = Field(default="", exclude=True)
|
otel_endpoint: str = Field(default="", exclude=True)
|
||||||
|
|
||||||
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
|
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
|
||||||
|
vibe_base_url: str = Field(default=DEFAULT_VIBE_BASE_URL, exclude=True)
|
||||||
|
|
||||||
enable_experimental_hooks: bool = Field(default=False, exclude=True)
|
enable_experimental_hooks: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
|
|
@ -1064,7 +1067,11 @@ class VibeConfig(BaseSettings):
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, **overrides: Any) -> VibeConfig:
|
def load(cls, **overrides: Any) -> VibeConfig:
|
||||||
cls._migrate()
|
cls._migrate()
|
||||||
return cls(**(overrides or {}))
|
config = cls(**(overrides or {}))
|
||||||
|
configure_ssl_context(
|
||||||
|
enable_system_trust_store=config.enable_system_trust_store
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_default(cls) -> dict[str, Any]:
|
def create_default(cls) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -324,12 +324,6 @@ class AnthropicAdapter(APIAdapter):
|
||||||
"prompt-caching-2024-07-31,"
|
"prompt-caching-2024-07-31,"
|
||||||
"context-1m-2025-08-07"
|
"context-1m-2025-08-07"
|
||||||
)
|
)
|
||||||
THINKING_BUDGETS: ClassVar[dict[str, int]] = {
|
|
||||||
"low": 1024,
|
|
||||||
"medium": 10_000,
|
|
||||||
"high": 32_000,
|
|
||||||
"max": 128_000,
|
|
||||||
}
|
|
||||||
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
|
DEFAULT_ADAPTIVE_MAX_TOKENS: ClassVar[int] = 32_768
|
||||||
DEFAULT_MAX_TOKENS = 8192
|
DEFAULT_MAX_TOKENS = 8192
|
||||||
|
|
||||||
|
|
@ -375,66 +369,32 @@ class AnthropicAdapter(APIAdapter):
|
||||||
if last_block.get("type") in {"text", "image", "tool_result"}:
|
if last_block.get("type") in {"text", "image", "tool_result"}:
|
||||||
last_block["cache_control"] = {"type": "ephemeral"}
|
last_block["cache_control"] = {"type": "ephemeral"}
|
||||||
|
|
||||||
# Anthropic models that require the `thinking={"type":"adaptive"}` +
|
|
||||||
# `output_config.effort` shape and reject the older
|
|
||||||
# `thinking={"type":"enabled","budget_tokens":...}` shape. Add new
|
|
||||||
# adaptive-only model families here as Anthropic ships them.
|
|
||||||
ADAPTIVE_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({"opus-4-6", "opus-4-7"})
|
|
||||||
|
|
||||||
# Anthropic models that have deprecated the `temperature` parameter and
|
|
||||||
# reject any payload containing it. Add new families here as Anthropic
|
|
||||||
# ships them.
|
|
||||||
TEMPERATURE_DEPRECATED_MODEL_TAGS: ClassVar[frozenset[str]] = frozenset({
|
|
||||||
"opus-4-7"
|
|
||||||
})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_adaptive_model(cls, model_name: str) -> bool:
|
|
||||||
return any(tag in model_name for tag in cls.ADAPTIVE_MODEL_TAGS)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_temperature_deprecated_model(cls, model_name: str) -> bool:
|
|
||||||
return any(tag in model_name for tag in cls.TEMPERATURE_DEPRECATED_MODEL_TAGS)
|
|
||||||
|
|
||||||
def _apply_thinking_config(
|
def _apply_thinking_config(
|
||||||
self,
|
self,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
model_name: str,
|
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
temperature: float,
|
|
||||||
max_tokens: int | None,
|
max_tokens: int | None,
|
||||||
thinking: str,
|
thinking: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
has_thinking = self._has_thinking_content(messages)
|
has_thinking = self._has_thinking_content(messages)
|
||||||
thinking_level = thinking
|
thinking_level = thinking
|
||||||
temperature_deprecated = self._is_temperature_deprecated_model(model_name)
|
|
||||||
|
|
||||||
if thinking_level == "off" and not has_thinking:
|
if thinking_level == "off" and not has_thinking:
|
||||||
if not temperature_deprecated:
|
payload["max_tokens"] = (
|
||||||
payload["temperature"] = temperature
|
max_tokens if max_tokens is not None else self.DEFAULT_MAX_TOKENS
|
||||||
if max_tokens is not None:
|
)
|
||||||
payload["max_tokens"] = max_tokens
|
|
||||||
else:
|
|
||||||
payload["max_tokens"] = self.DEFAULT_MAX_TOKENS
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Resolve effective level: use config, or fallback to "medium" when
|
# Resolve effective level: use config, or fallback to "medium" when
|
||||||
# forced by thinking content in history
|
# forced by thinking content in history
|
||||||
effective_level = thinking_level if thinking_level != "off" else "medium"
|
effective_level = thinking_level if thinking_level != "off" else "medium"
|
||||||
|
|
||||||
if self._is_adaptive_model(model_name):
|
payload["thinking"] = {"type": "adaptive", "display": "summarized"}
|
||||||
payload["thinking"] = {"type": "adaptive", "display": "summarized"}
|
payload["output_config"] = {"effort": effective_level}
|
||||||
payload["output_config"] = {"effort": effective_level}
|
payload["max_tokens"] = (
|
||||||
default_max = self.DEFAULT_ADAPTIVE_MAX_TOKENS
|
max_tokens if max_tokens is not None else 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
|
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
self,
|
self,
|
||||||
|
|
@ -442,7 +402,6 @@ class AnthropicAdapter(APIAdapter):
|
||||||
model_name: str,
|
model_name: str,
|
||||||
system_prompt: str | None,
|
system_prompt: str | None,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
temperature: float,
|
|
||||||
tools: list[dict[str, Any]] | None,
|
tools: list[dict[str, Any]] | None,
|
||||||
max_tokens: int | None,
|
max_tokens: int | None,
|
||||||
tool_choice: dict[str, Any] | None,
|
tool_choice: dict[str, Any] | None,
|
||||||
|
|
@ -452,12 +411,7 @@ class AnthropicAdapter(APIAdapter):
|
||||||
payload: dict[str, Any] = {"model": model_name, "messages": messages}
|
payload: dict[str, Any] = {"model": model_name, "messages": messages}
|
||||||
|
|
||||||
self._apply_thinking_config(
|
self._apply_thinking_config(
|
||||||
payload,
|
payload, messages=messages, max_tokens=max_tokens, thinking=thinking
|
||||||
model_name=model_name,
|
|
||||||
messages=messages,
|
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
|
||||||
thinking=thinking,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if system_blocks := self._build_system_blocks(system_prompt):
|
if system_blocks := self._build_system_blocks(system_prompt):
|
||||||
|
|
@ -498,7 +452,6 @@ class AnthropicAdapter(APIAdapter):
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=converted_messages,
|
messages=converted_messages,
|
||||||
temperature=temperature,
|
|
||||||
tools=converted_tools,
|
tools=converted_tools,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
tool_choice=converted_tool_choice,
|
tool_choice=converted_tool_choice,
|
||||||
|
|
|
||||||
|
|
@ -416,35 +416,6 @@ class GenericBackend:
|
||||||
return
|
return
|
||||||
yield json.loads(value.strip())
|
yield json.loads(value.strip())
|
||||||
|
|
||||||
async def count_tokens(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
model: ModelConfig,
|
|
||||||
messages: Sequence[LLMMessage],
|
|
||||||
temperature: float = 0.0,
|
|
||||||
tools: list[AvailableTool] | None = None,
|
|
||||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
|
||||||
extra_headers: dict[str, str] | None = None,
|
|
||||||
metadata: dict[str, str] | None = None,
|
|
||||||
) -> int:
|
|
||||||
probe_messages = list(messages)
|
|
||||||
if not probe_messages or probe_messages[-1].role != Role.user:
|
|
||||||
probe_messages.append(LLMMessage(role=Role.user, content=""))
|
|
||||||
|
|
||||||
result = await self.complete(
|
|
||||||
model=model,
|
|
||||||
messages=probe_messages,
|
|
||||||
temperature=temperature,
|
|
||||||
tools=tools,
|
|
||||||
max_tokens=16, # Minimal amount for openrouter with openai models
|
|
||||||
tool_choice=tool_choice,
|
|
||||||
extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
if result.usage is None:
|
|
||||||
raise ValueError("Missing usage in non streaming completion")
|
|
||||||
|
|
||||||
return result.usage.prompt_tokens
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._owns_client and self._client:
|
if self._owns_client and self._client:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
|
|
||||||
|
|
@ -426,29 +426,3 @@ class MistralBackend:
|
||||||
has_tools=bool(tools),
|
has_tools=bool(tools),
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
async def count_tokens(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
model: ModelConfig,
|
|
||||||
messages: Sequence[LLMMessage],
|
|
||||||
temperature: float = 0.0,
|
|
||||||
tools: list[AvailableTool] | None = None,
|
|
||||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
|
||||||
extra_headers: dict[str, str] | None = None,
|
|
||||||
metadata: dict[str, str] | None = None,
|
|
||||||
) -> int:
|
|
||||||
result = await self.complete(
|
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
temperature=temperature,
|
|
||||||
tools=tools,
|
|
||||||
max_tokens=1,
|
|
||||||
tool_choice=tool_choice,
|
|
||||||
extra_headers=extra_headers,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
if result.usage is None:
|
|
||||||
raise ValueError("Missing usage in non streaming completion")
|
|
||||||
|
|
||||||
return result.usage.prompt_tokens
|
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,7 @@ class VertexAnthropicAdapter(AnthropicAdapter):
|
||||||
}
|
}
|
||||||
self._apply_thinking_config(
|
self._apply_thinking_config(
|
||||||
payload,
|
payload,
|
||||||
model_name=model_name,
|
|
||||||
messages=converted_messages,
|
messages=converted_messages,
|
||||||
temperature=temperature,
|
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class BackendLike(Protocol):
|
||||||
"""Port protocol for dependency-injectable LLM backends.
|
"""Port protocol for dependency-injectable LLM backends.
|
||||||
|
|
||||||
Any backend used by AgentLoop should implement this async context manager
|
Any backend used by AgentLoop should implement this async context manager
|
||||||
interface with `complete`, `complete_streaming` and `count_tokens` methods.
|
interface with `complete` and `complete_streaming` methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def __aenter__(self) -> BackendLike: ...
|
async def __aenter__(self) -> BackendLike: ...
|
||||||
|
|
@ -92,35 +92,3 @@ class BackendLike(Protocol):
|
||||||
BackendError: If the API request fails
|
BackendError: If the API request fails
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def count_tokens(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
model: ModelConfig,
|
|
||||||
messages: Sequence[LLMMessage],
|
|
||||||
temperature: float = 0.0,
|
|
||||||
tools: list[AvailableTool] | None,
|
|
||||||
tool_choice: StrToolChoice | AvailableTool | None = None,
|
|
||||||
extra_headers: dict[str, str] | None,
|
|
||||||
metadata: dict[str, str] | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Count the number of tokens in the prompt without generating a real response.
|
|
||||||
|
|
||||||
This is useful for:
|
|
||||||
- Determining system prompt token count
|
|
||||||
- Checking context size after compaction
|
|
||||||
- Pre-flight token validation
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model: Model configuration
|
|
||||||
messages: List of messages to count tokens for
|
|
||||||
temperature: Sampling temperature
|
|
||||||
tools: Optional list of available tools
|
|
||||||
tool_choice: How to choose tools
|
|
||||||
extra_headers: Additional HTTP headers to include
|
|
||||||
metadata: Optional metadata to attach to the request
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The number of prompt tokens
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ enable_telemetry = true
|
||||||
enable_update_checks = true
|
enable_update_checks = true
|
||||||
enable_auto_update = true
|
enable_auto_update = true
|
||||||
enable_notifications = true
|
enable_notifications = true
|
||||||
|
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
|
||||||
api_timeout = 720.0 # API request timeout in seconds
|
api_timeout = 720.0 # API request timeout in seconds
|
||||||
auto_compact_threshold = 200000 # Token count before auto-compaction
|
auto_compact_threshold = 200000 # Token count before auto-compaction
|
||||||
|
|
||||||
|
|
@ -267,6 +268,13 @@ browser_auth_base_url = "https://console.mistral.ai"
|
||||||
browser_auth_api_base_url = "https://console.mistral.ai/api"
|
browser_auth_api_base_url = "https://console.mistral.ai/api"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Self-hosted deployments can point Vibe CLI upgrade and API-key links to their
|
||||||
|
Le Chat web deployment, where the Vibe API key is managed:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
vibe_base_url = "https://chat.mistral.ai"
|
||||||
|
```
|
||||||
|
|
||||||
### Hooks (Experimental)
|
### Hooks (Experimental)
|
||||||
|
|
||||||
Hooks let users run shell commands automatically at specific points during a
|
Hooks let users run shell commands automatically at specific points during a
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,6 @@ class TelemetryClient:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
nb_context_tokens_before: int,
|
nb_context_tokens_before: int,
|
||||||
nb_context_tokens_after: int,
|
|
||||||
auto_compact_threshold: int,
|
auto_compact_threshold: int,
|
||||||
status: Literal["success", "failure", "cancelled"],
|
status: Literal["success", "failure", "cancelled"],
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
|
|
@ -253,7 +252,6 @@ class TelemetryClient:
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"nb_context_tokens_before": nb_context_tokens_before,
|
"nb_context_tokens_before": nb_context_tokens_before,
|
||||||
"nb_context_tokens_after": nb_context_tokens_after,
|
|
||||||
"auto_compact_threshold": auto_compact_threshold,
|
"auto_compact_threshold": auto_compact_threshold,
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from vibe.core.tools.mcp.tools import (
|
||||||
call_tool_stdio,
|
call_tool_stdio,
|
||||||
create_mcp_http_proxy_tool_class,
|
create_mcp_http_proxy_tool_class,
|
||||||
create_mcp_stdio_proxy_tool_class,
|
create_mcp_stdio_proxy_tool_class,
|
||||||
|
create_vibe_mcp_http_client,
|
||||||
list_tools_http,
|
list_tools_http,
|
||||||
list_tools_stdio,
|
list_tools_stdio,
|
||||||
)
|
)
|
||||||
|
|
@ -26,6 +27,7 @@ __all__ = [
|
||||||
"call_tool_stdio",
|
"call_tool_stdio",
|
||||||
"create_mcp_http_proxy_tool_class",
|
"create_mcp_http_proxy_tool_class",
|
||||||
"create_mcp_stdio_proxy_tool_class",
|
"create_mcp_stdio_proxy_tool_class",
|
||||||
|
"create_vibe_mcp_http_client",
|
||||||
"list_tools_http",
|
"list_tools_http",
|
||||||
"list_tools_stdio",
|
"list_tools_stdio",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
|
from typing import TYPE_CHECKING, Any, ClassVar, TextIO
|
||||||
|
|
||||||
|
import httpx
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from vibe.core.logger import logger
|
from vibe.core.logger import logger
|
||||||
from vibe.core.tools.base import (
|
from vibe.core.tools.base import (
|
||||||
BaseTool,
|
BaseTool,
|
||||||
|
|
@ -25,11 +26,18 @@ from vibe.core.tools.base import (
|
||||||
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
from vibe.core.tools.mcp_sampling import MCPSamplingHandler
|
||||||
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
|
from vibe.core.tools.ui import ToolResultDisplay, ToolUIData
|
||||||
from vibe.core.types import ToolStreamEvent
|
from vibe.core.types import ToolStreamEvent
|
||||||
|
from vibe.core.utils.http import build_ssl_context
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from vibe.core.types import ToolResultEvent
|
from vibe.core.types import ToolResultEvent
|
||||||
|
|
||||||
|
|
||||||
|
# Mirrors MCP's default Streamable HTTP timeout values while avoiding an import from
|
||||||
|
# mcp.shared._httpx_utils, which is an internal module.
|
||||||
|
_MCP_DEFAULT_TIMEOUT = 30.0
|
||||||
|
_MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0
|
||||||
|
|
||||||
|
|
||||||
def _stderr_logger_thread(read_fd: int) -> None:
|
def _stderr_logger_thread(read_fd: int) -> None:
|
||||||
with open(read_fd, "rb") as f:
|
with open(read_fd, "rb") as f:
|
||||||
for line in iter(f.readline, b""):
|
for line in iter(f.readline, b""):
|
||||||
|
|
@ -167,6 +175,15 @@ def _parse_call_result(server: str, tool: str, result_obj: Any) -> MCPToolResult
|
||||||
return MCPToolResult(server=server, tool=tool, text=text, structured=None)
|
return MCPToolResult(server=server, tool=tool, text=text, structured=None)
|
||||||
|
|
||||||
|
|
||||||
|
def create_vibe_mcp_http_client(headers: dict[str, str] | None) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
follow_redirects=True,
|
||||||
|
headers=headers,
|
||||||
|
timeout=httpx.Timeout(_MCP_DEFAULT_TIMEOUT, read=_MCP_DEFAULT_SSE_READ_TIMEOUT),
|
||||||
|
verify=build_ssl_context(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_tools_http(
|
async def list_tools_http(
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
|
|
@ -174,11 +191,18 @@ async def list_tools_http(
|
||||||
startup_timeout_sec: float | None = None,
|
startup_timeout_sec: float | None = None,
|
||||||
) -> list[RemoteTool]:
|
) -> list[RemoteTool]:
|
||||||
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
||||||
async with streamablehttp_client(url, headers=headers) as (read, write, _):
|
async with create_vibe_mcp_http_client(headers) as http_client:
|
||||||
async with ClientSession(read, write, read_timeout_seconds=timeout) as session:
|
async with streamable_http_client(url, http_client=http_client) as (
|
||||||
await session.initialize()
|
read,
|
||||||
tools_resp = await session.list_tools()
|
write,
|
||||||
return [RemoteTool.model_validate(t) for t in tools_resp.tools]
|
_,
|
||||||
|
):
|
||||||
|
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(
|
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
|
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
|
||||||
)
|
)
|
||||||
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
|
call_timeout = timedelta(seconds=tool_timeout_sec) if tool_timeout_sec else None
|
||||||
async with streamablehttp_client(url, headers=headers) as (read, write, _):
|
async with create_vibe_mcp_http_client(headers) as http_client:
|
||||||
async with ClientSession(
|
async with streamable_http_client(url, http_client=http_client) as (
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
read_timeout_seconds=init_timeout,
|
_,
|
||||||
sampling_callback=sampling_callback,
|
):
|
||||||
) as session:
|
async with ClientSession(
|
||||||
await session.initialize()
|
read,
|
||||||
result = await session.call_tool(
|
write,
|
||||||
tool_name, arguments, read_timeout_seconds=call_timeout
|
read_timeout_seconds=init_timeout,
|
||||||
)
|
sampling_callback=sampling_callback,
|
||||||
return _parse_call_result(url, tool_name, result)
|
) 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(
|
def create_mcp_http_proxy_tool_class(
|
||||||
|
|
|
||||||
|
|
@ -430,8 +430,6 @@ class CompactStartEvent(BaseEvent):
|
||||||
|
|
||||||
|
|
||||||
class CompactEndEvent(BaseEvent):
|
class CompactEndEvent(BaseEvent):
|
||||||
old_context_tokens: int
|
|
||||||
new_context_tokens: int
|
|
||||||
summary_length: int
|
summary_length: int
|
||||||
old_session_id: str | None = None
|
old_session_id: str | None = None
|
||||||
new_session_id: str | None = None
|
new_session_id: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ from vibe.core.utils.concurrency import (
|
||||||
ConversationLimitException,
|
ConversationLimitException,
|
||||||
run_sync,
|
run_sync,
|
||||||
)
|
)
|
||||||
from vibe.core.utils.display import compact_reduction_display
|
from vibe.core.utils.display import compact_complete_display
|
||||||
from vibe.core.utils.http import (
|
from vibe.core.utils.http import (
|
||||||
build_ssl_context,
|
build_ssl_context,
|
||||||
|
configure_ssl_context,
|
||||||
get_server_url_from_api_base,
|
get_server_url_from_api_base,
|
||||||
get_user_agent,
|
get_user_agent,
|
||||||
)
|
)
|
||||||
|
|
@ -55,7 +56,8 @@ __all__ = [
|
||||||
"async_generator_retry",
|
"async_generator_retry",
|
||||||
"async_retry",
|
"async_retry",
|
||||||
"build_ssl_context",
|
"build_ssl_context",
|
||||||
"compact_reduction_display",
|
"compact_complete_display",
|
||||||
|
"configure_ssl_context",
|
||||||
"get_platform_display_name",
|
"get_platform_display_name",
|
||||||
"get_platform_id",
|
"get_platform_id",
|
||||||
"get_server_url_from_api_base",
|
"get_server_url_from_api_base",
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,11 @@ from __future__ import annotations
|
||||||
from vibe.core.session.session_id import shorten_session_id
|
from vibe.core.session.session_id import shorten_session_id
|
||||||
|
|
||||||
|
|
||||||
def compact_reduction_display(
|
def compact_complete_display(
|
||||||
old_tokens: int | None,
|
old_session_id: str | None = None, new_session_id: str | None = None
|
||||||
new_tokens: int | None,
|
|
||||||
*,
|
|
||||||
old_session_id: str | None = None,
|
|
||||||
new_session_id: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|
||||||
message = "Compaction complete"
|
message = "Compaction completed."
|
||||||
if old_tokens is not None and new_tokens is not None:
|
|
||||||
reduction = old_tokens - new_tokens
|
|
||||||
reduction_pct = (reduction / old_tokens * 100) if old_tokens > 0 else 0
|
|
||||||
message = (
|
|
||||||
f"{message}: {old_tokens:,} → "
|
|
||||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if old_session_id is not None and new_session_id is not None:
|
if old_session_id is not None and new_session_id is not None:
|
||||||
short_old = shorten_session_id(old_session_id)
|
short_old = shorten_session_id(old_session_id)
|
||||||
short_new = shorten_session_id(new_session_id)
|
short_new = shorten_session_id(new_session_id)
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,28 @@ import re
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
import certifi
|
import certifi
|
||||||
|
import truststore
|
||||||
|
|
||||||
from vibe import __version__
|
from vibe import __version__
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
|
|
||||||
|
_use_system_trust_store = False
|
||||||
|
|
||||||
|
|
||||||
|
def configure_ssl_context(*, enable_system_trust_store: bool) -> None:
|
||||||
|
global _use_system_trust_store
|
||||||
|
if _use_system_trust_store == enable_system_trust_store:
|
||||||
|
return
|
||||||
|
_use_system_trust_store = enable_system_trust_store
|
||||||
|
build_ssl_context.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
def build_ssl_context() -> ssl.SSLContext:
|
def build_ssl_context() -> ssl.SSLContext:
|
||||||
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.
|
# Custom certs are additive so private-CA users don't lose public roots.
|
||||||
ssl_cert_file = os.getenv("SSL_CERT_FILE")
|
ssl_cert_file = os.getenv("SSL_CERT_FILE")
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ def assess_auth_state(
|
||||||
return _auth_state(
|
return _auth_state(
|
||||||
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
|
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
|
||||||
can_use_active_provider=True,
|
can_use_active_provider=True,
|
||||||
|
sign_out_available=True,
|
||||||
env_key=env_key,
|
env_key=env_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class OnboardingApp(App[str | None]):
|
||||||
|
|
||||||
self._config = config
|
self._config = config
|
||||||
self._provider = config.provider
|
self._provider = config.provider
|
||||||
|
self._vibe_base_url = config.vibe_base_url
|
||||||
self._entrypoint_metadata = entrypoint_metadata
|
self._entrypoint_metadata = entrypoint_metadata
|
||||||
self._browser_sign_in_success_delay = browser_sign_in_success_delay
|
self._browser_sign_in_success_delay = browser_sign_in_success_delay
|
||||||
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
|
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
|
||||||
|
|
@ -58,7 +59,11 @@ class OnboardingApp(App[str | None]):
|
||||||
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
|
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
|
||||||
)
|
)
|
||||||
self.install_screen(
|
self.install_screen(
|
||||||
ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata),
|
ApiKeyScreen(
|
||||||
|
self._provider,
|
||||||
|
vibe_base_url=self._vibe_base_url,
|
||||||
|
entrypoint_metadata=self._entrypoint_metadata,
|
||||||
|
),
|
||||||
"api_key",
|
"api_key",
|
||||||
)
|
)
|
||||||
if self._browser_sign_in_service_factory is not None:
|
if self._browser_sign_in_service_factory is not None:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from vibe.core.config._settings import (
|
||||||
DEFAULT_ACTIVE_MODEL,
|
DEFAULT_ACTIVE_MODEL,
|
||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
DEFAULT_PROVIDERS,
|
DEFAULT_PROVIDERS,
|
||||||
|
DEFAULT_VIBE_BASE_URL,
|
||||||
)
|
)
|
||||||
from vibe.core.config.harness_files import get_harness_files_manager
|
from vibe.core.config.harness_files import get_harness_files_manager
|
||||||
from vibe.core.logger import logger
|
from vibe.core.logger import logger
|
||||||
|
|
@ -29,6 +30,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
|
||||||
|
|
||||||
class _OnboardingSnapshot(BaseModel):
|
class _OnboardingSnapshot(BaseModel):
|
||||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||||
|
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
|
||||||
providers: list[Any] = Field(default_factory=_default_provider_payloads)
|
providers: list[Any] = Field(default_factory=_default_provider_payloads)
|
||||||
models: list[Any] = Field(default_factory=_default_model_payloads)
|
models: list[Any] = Field(default_factory=_default_model_payloads)
|
||||||
|
|
||||||
|
|
@ -97,6 +99,12 @@ def _load_onboarding_env_payload_for_fields(
|
||||||
and (models := _find_env_value("VIBE_MODELS")) is not None
|
and (models := _find_env_value("VIBE_MODELS")) is not None
|
||||||
):
|
):
|
||||||
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
|
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
|
||||||
|
if (
|
||||||
|
"vibe_base_url" in field_names
|
||||||
|
and (vibe_base_url := _find_env_value("VIBE_VIBE_BASE_URL")) is not None
|
||||||
|
):
|
||||||
|
payload["vibe_base_url"] = vibe_base_url
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,6 +188,7 @@ def _resolve_provider(
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OnboardingContext:
|
class OnboardingContext:
|
||||||
provider: ProviderConfig
|
provider: ProviderConfig
|
||||||
|
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_browser_sign_in(self) -> bool:
|
def supports_browser_sign_in(self) -> bool:
|
||||||
|
|
@ -187,7 +196,9 @@ class OnboardingContext:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: VibeConfig) -> OnboardingContext:
|
def from_config(cls, config: VibeConfig) -> OnboardingContext:
|
||||||
return cls(provider=config.get_active_provider())
|
return cls(
|
||||||
|
provider=config.get_active_provider(), vibe_base_url=config.vibe_base_url
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, **overrides: Any) -> OnboardingContext:
|
def load(cls, **overrides: Any) -> OnboardingContext:
|
||||||
|
|
@ -198,7 +209,8 @@ class OnboardingContext:
|
||||||
return cls(
|
return cls(
|
||||||
provider=_resolve_provider(
|
provider=_resolve_provider(
|
||||||
active_model=snapshot.active_model, snapshot=snapshot
|
active_model=snapshot.active_model, snapshot=snapshot
|
||||||
)
|
),
|
||||||
|
vibe_base_url=snapshot.vibe_base_url,
|
||||||
)
|
)
|
||||||
except (RuntimeError, ValidationError, ValueError):
|
except (RuntimeError, ValidationError, ValueError):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from textual.widgets import Input, Link
|
||||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||||
from vibe.core.config import ProviderConfig
|
from vibe.core.config import DEFAULT_VIBE_BASE_URL, ProviderConfig
|
||||||
from vibe.core.telemetry.types import EntrypointMetadata
|
from vibe.core.telemetry.types import EntrypointMetadata
|
||||||
from vibe.setup.auth.api_key_persistence import (
|
from vibe.setup.auth.api_key_persistence import (
|
||||||
persist_api_key,
|
persist_api_key,
|
||||||
|
|
@ -20,8 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
|
||||||
)
|
)
|
||||||
from vibe.setup.onboarding.base import OnboardingScreen
|
from vibe.setup.onboarding.base import OnboardingScreen
|
||||||
|
|
||||||
# providers with a dedicated key creation page get a direct onboarding link
|
MISTRAL_PROVIDER_NAME = "mistral"
|
||||||
PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")}
|
MISTRAL_PROVIDER_HELP_NAME = "Mistral Vibe"
|
||||||
CONFIG_DOCS_URL = (
|
CONFIG_DOCS_URL = (
|
||||||
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
|
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
|
||||||
)
|
)
|
||||||
|
|
@ -39,17 +39,19 @@ class ApiKeyScreen(OnboardingScreen):
|
||||||
self,
|
self,
|
||||||
provider: ProviderConfig | None = None,
|
provider: ProviderConfig | None = None,
|
||||||
*,
|
*,
|
||||||
|
vibe_base_url: str = DEFAULT_VIBE_BASE_URL,
|
||||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.provider = resolve_api_key_provider(provider)
|
self.provider = resolve_api_key_provider(provider)
|
||||||
|
self._vibe_base_url = vibe_base_url
|
||||||
self._entrypoint_metadata = entrypoint_metadata
|
self._entrypoint_metadata = entrypoint_metadata
|
||||||
|
|
||||||
def _compose_provider_link(self) -> ComposeResult:
|
def _compose_provider_link(self) -> ComposeResult:
|
||||||
if (help_info := PROVIDER_HELP.get(self.provider.name)) is None:
|
if self.provider.name != MISTRAL_PROVIDER_NAME:
|
||||||
return
|
return
|
||||||
|
|
||||||
help_url, _ = help_info
|
help_url = f"{self._vibe_base_url.rstrip('/')}/code/extensions?focus=key"
|
||||||
yield Link(help_url, url=help_url, id="api-key-provider-link")
|
yield Link(help_url, url=help_url, id="api-key-provider-link")
|
||||||
|
|
||||||
def _compose_config_docs(self) -> ComposeResult:
|
def _compose_config_docs(self) -> ComposeResult:
|
||||||
|
|
@ -60,8 +62,11 @@ class ApiKeyScreen(OnboardingScreen):
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
provider_name = self.provider.name.capitalize()
|
provider_name = self.provider.name.capitalize()
|
||||||
help_info = PROVIDER_HELP.get(self.provider.name)
|
help_name = (
|
||||||
help_name = help_info[1] if help_info is not None else "your provider"
|
MISTRAL_PROVIDER_HELP_NAME
|
||||||
|
if self.provider.name == MISTRAL_PROVIDER_NAME
|
||||||
|
else "your provider"
|
||||||
|
)
|
||||||
|
|
||||||
self.input_widget = Input(
|
self.input_widget = Input(
|
||||||
password=True,
|
password=True,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# What's new in v2.12.1
|
# What's new in v2.13.0
|
||||||
|
|
||||||
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
|
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
|
||||||
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.
|
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue