diff --git a/.vscode/launch.json b/.vscode/launch.json index 5b96cf2..58582f5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "1.1.1", + "version": "1.1.2", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f4511..d7bc96b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.2] - 2025-12-11 + +### Changed + +- add `terminal-auth` auth method to ACP agent only if the client supports it +- fix `user-agent` header when using Mistral backend, using SDK hook + ## [1.1.1] - 2025-12-10 ### Changed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index b7fd0e6..f068102 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "1.1.1" +version = "1.1.2" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-darwin-aarch64-1.1.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-darwin-aarch64-1.1.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-darwin-x86_64-1.1.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-darwin-x86_64-1.1.2.zip" cmd = "./vibe-acp" # [agent_servers.mistral-vibe.targets.linux-aarch64] -# archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-linux-aarch64-1.1.1.zip" +# archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-linux-aarch64-1.1.2.zip" # cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-linux-x86_64-1.1.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-linux-x86_64-1.1.2.zip" cmd = "./vibe-acp" # [agent_servers.mistral-vibe.targets.windows-aarch64] -# archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-windows-aarch64-1.1.1.zip" +# archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-windows-aarch64-1.1.2.zip" # cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.1/vibe-acp-windows-x86_64-1.1.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.1.2/vibe-acp-windows-x86_64-1.1.2.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 14ddeb4..b3eb550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "1.1.1" +version = "1.1.2" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/bump_version.py b/scripts/bump_version.py index c608df4..7ca2a5a 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -134,9 +134,9 @@ Examples: "vibe/core/__init__.py", [(f'__version__ = "{current_version}"', f'__version__ = "{new_version}"')], ) - # Update tests/acp/test_acp.py + # Update tests/acp/test_initialize.py update_hard_values_files( - "tests/acp/test_acp.py", + "tests/acp/test_initialize.py", [(f'version="{current_version}"', f'version="{new_version}"')], ) diff --git a/tests/acp/test_acp.py b/tests/acp/test_acp.py index a47a60f..3b1fb4d 100644 --- a/tests/acp/test_acp.py +++ b/tests/acp/test_acp.py @@ -17,14 +17,10 @@ from acp import ( WriteTextFileRequest, ) from acp.schema import ( - AgentCapabilities, AllowedOutcome, DeniedOutcome, - Implementation, InitializeResponse, - McpCapabilities, NewSessionResponse, - PromptCapabilities, PromptResponse, SessionNotification, TextContentBlock, @@ -324,48 +320,6 @@ async def initialize_session(acp_agent_process: asyncio.subprocess.Process) -> s return session_response_obj.result.sessionId -class TestInitialization: - @pytest.mark.asyncio - async def test_initialize_request_response(self) -> None: - mock_env = get_mocking_env() - async for process in get_acp_agent_process(mock_env=mock_env): - await send_json_rpc( - process, - InitializeJsonRpcRequest( - id=1, params=InitializeRequest(protocolVersion=1) - ), - ) - - text_response = await read_response(process, timeout=10.0) - assert text_response is not None, "No response to initialize" - response_json = json.loads(text_response) - response = InitializeJsonRpcResponse.model_validate(response_json) - assert response.error is None, f"JSON-RPC error: {response.error}" - assert response.result is not None, "No result in response" - assert response.result.protocolVersion == 1 - assert response.result.agentCapabilities == AgentCapabilities( - loadSession=False, - promptCapabilities=PromptCapabilities( - audio=False, embeddedContext=True, image=False - ), - mcpCapabilities=McpCapabilities(http=False, sse=False), - ) - assert response.result.agentInfo == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.1" - ) - vibe_setup_method = next( - ( - method - for method in response.result.authMethods or [] - if method.id == "vibe-setup" - ), - None, - ) - assert vibe_setup_method is not None, "vibe-setup auth not found" - assert vibe_setup_method.field_meta is not None - assert "terminal-auth" in vibe_setup_method.field_meta.keys() - - class TestSessionManagement: @pytest.mark.asyncio async def test_multiple_sessions_unique_ids(self) -> None: diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py new file mode 100644 index 0000000..eb76551 --- /dev/null +++ b/tests/acp/test_initialize.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from acp import PROTOCOL_VERSION, AgentSideConnection, InitializeRequest +from acp.schema import ( + AgentCapabilities, + ClientCapabilities, + Implementation, + PromptCapabilities, +) +import pytest + +from tests.stubs.fake_connection import FakeAgentSideConnection +from vibe.acp.acp_agent import VibeAcpAgent + + +@pytest.fixture +def acp_agent() -> VibeAcpAgent: + vibe_acp_agent: VibeAcpAgent | None = None + + def _create_agent(connection: AgentSideConnection) -> VibeAcpAgent: + nonlocal vibe_acp_agent + vibe_acp_agent = VibeAcpAgent(connection) + return vibe_acp_agent + + FakeAgentSideConnection(_create_agent) + return vibe_acp_agent # pyright: ignore[reportReturnType] + + +class TestACPInitialize: + @pytest.mark.asyncio + async def test_initialize(self, acp_agent: VibeAcpAgent) -> None: + """Test regular initialize without terminal-auth capabilities.""" + request = InitializeRequest(protocolVersion=PROTOCOL_VERSION) + response = await acp_agent.initialize(request) + + assert response.protocolVersion == PROTOCOL_VERSION + assert response.agentCapabilities == AgentCapabilities( + loadSession=False, + promptCapabilities=PromptCapabilities( + audio=False, embeddedContext=True, image=False + ), + ) + assert response.agentInfo == Implementation( + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.2" + ) + + assert response.authMethods == [] + + @pytest.mark.asyncio + async def test_initialize_with_terminal_auth(self, acp_agent: VibeAcpAgent) -> None: + """Test initialize with terminal-auth capabilities to check it was included.""" + client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True}) + request = InitializeRequest( + protocolVersion=PROTOCOL_VERSION, clientCapabilities=client_capabilities + ) + response = await acp_agent.initialize(request) + + assert response.protocolVersion == PROTOCOL_VERSION + assert response.agentCapabilities == AgentCapabilities( + loadSession=False, + promptCapabilities=PromptCapabilities( + audio=False, embeddedContext=True, image=False + ), + ) + assert response.agentInfo == Implementation( + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.1.2" + ) + + assert response.authMethods is not None + assert len(response.authMethods) == 1 + auth_method = response.authMethods[0] + assert auth_method.id == "vibe-setup" + assert auth_method.name == "Register your API Key" + assert auth_method.description == "Register your API Key inside Mistral Vibe" + assert auth_method.field_meta is not None + assert "terminal-auth" in auth_method.field_meta + terminal_auth_meta = auth_method.field_meta["terminal-auth"] + assert "command" in terminal_auth_meta + assert "args" in terminal_auth_meta + assert terminal_auth_meta["label"] == "Mistral Vibe Setup" diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index 93c4048..0ccdac7 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -29,12 +29,14 @@ from tests.backend.data.mistral import ( STREAMED_TOOL_CONVERSATION_PARAMS as MISTRAL_STREAMED_TOOL_CONVERSATION_PARAMS, TOOL_CONVERSATION_PARAMS as MISTRAL_TOOL_CONVERSATION_PARAMS, ) -from vibe.core.config import ModelConfig, ProviderConfig +from vibe.core.config import Backend, ModelConfig, ProviderConfig +from vibe.core.llm.backend.factory import BACKEND_FACTORY from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.mistral import MistralBackend from vibe.core.llm.exceptions import BackendError from vibe.core.llm.types import BackendLike from vibe.core.types import LLMChunk, LLMMessage, Role, ToolCall +from vibe.core.utils import get_user_agent class TestBackend: @@ -246,3 +248,99 @@ class TestBackend: assert e.value.status == response.status_code assert e.value.reason == response.reason_phrase assert e.value.parsed_error is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("backend_type", [Backend.MISTRAL, Backend.GENERIC]) + async def test_backend_user_agent(self, backend_type: Backend): + user_agent = get_user_agent(backend_type) + base_url = "https://api.example.com" + json_response = { + "id": "fake_id_1234", + "created": 1234567890, + "model": "devstral-latest", + "usage": { + "prompt_tokens": 100, + "total_tokens": 300, + "completion_tokens": 200, + }, + "object": "chat.completion", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "tool_calls": None, + "content": "Hey", + }, + } + ], + } + with respx.mock(base_url=base_url) as mock_api: + mock_api.post("/v1/chat/completions").mock( + return_value=httpx.Response(status_code=200, json=json_response) + ) + + provider = ProviderConfig( + name="provider_name", + api_base=f"{base_url}/v1", + api_key_env_var="API_KEY", + ) + backend = BACKEND_FACTORY[backend_type](provider=provider) + model = ModelConfig( + name="model_name", provider="provider_name", alias="model_alias" + ) + messages = [LLMMessage(role=Role.user, content="Just say hi")] + + await backend.complete( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers={"user-agent": user_agent}, + ) + + assert mock_api.calls.last.request.headers["user-agent"] == user_agent + + @pytest.mark.asyncio + @pytest.mark.parametrize("backend_type", [Backend.MISTRAL, Backend.GENERIC]) + async def test_backend_user_agent_when_streaming(self, backend_type: Backend): + user_agent = get_user_agent(backend_type) + + base_url = "https://api.example.com" + with respx.mock(base_url=base_url) as mock_api: + chunks = [ + rb'data: {"id":"fake_id_1234","object":"chat.completion.chunk","created":1234567890,"model":"devstral-latest","choices":[{"index":0,"delta":{"role":"assistant","content":"Hey"},"finish_reason":"stop"}]}' + ] + mock_response = httpx.Response( + status_code=200, + stream=httpx.ByteStream(stream=b"\n\n".join(chunks)), + headers={"Content-Type": "text/event-stream"}, + ) + mock_api.post("/v1/chat/completions").mock(return_value=mock_response) + + provider = ProviderConfig( + name="provider_name", + api_base=f"{base_url}/v1", + api_key_env_var="API_KEY", + ) + backend = BACKEND_FACTORY[backend_type](provider=provider) + model = ModelConfig( + name="model_name", provider="provider_name", alias="model_alias" + ) + messages = [LLMMessage(role=Role.user, content="Just say hi")] + + async for _ in backend.complete_streaming( + model=model, + messages=messages, + temperature=0.2, + tools=None, + max_tokens=None, + tool_choice=None, + extra_headers={"user-agent": user_agent}, + ): + pass + + assert mock_api.calls.last.request.headers["user-agent"] == user_agent diff --git a/uv.lock b/uv.lock index 9cdbdc9..cbffd62 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "1.1.1" +version = "1.1.2" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/acp/acp_agent.py b/vibe/acp/acp_agent.py index b8c610d..c30246d 100644 --- a/vibe/acp/acp_agent.py +++ b/vibe/acp/acp_agent.py @@ -100,20 +100,30 @@ class VibeAcpAgent(AcpAgent): script_name = sys.argv[0] args = [script_name, "--setup"] - auth_methods = [ - AuthMethod( - id="vibe-setup", - name="Register your API Key", - description="Register your API Key inside Mistral Vibe", - field_meta={ - "terminal-auth": { - "command": command, - "args": args, - "label": "Mistral Vibe Setup", - } - }, - ) - ] + supports_terminal_auth = ( + self.client_capabilities + and self.client_capabilities.field_meta + and self.client_capabilities.field_meta.get("terminal-auth") is True + ) + + auth_methods = ( + [ + AuthMethod( + id="vibe-setup", + name="Register your API Key", + description="Register your API Key inside Mistral Vibe", + field_meta={ + "terminal-auth": { + "command": command, + "args": args, + "label": "Mistral Vibe Setup", + } + }, + ) + ] + if supports_terminal_auth + else [] + ) response = InitializeResponse( agentCapabilities=AgentCapabilities( diff --git a/vibe/core/__init__.py b/vibe/core/__init__.py index 8797205..55e1056 100644 --- a/vibe/core/__init__.py +++ b/vibe/core/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations __all__ = ["__version__", "run_programmatic"] -__version__ = "1.1.1" +__version__ = "1.1.2" from vibe.core.programmatic import run_programmatic diff --git a/vibe/core/agent.py b/vibe/core/agent.py index da31cfd..c2da6c7 100644 --- a/vibe/core/agent.py +++ b/vibe/core/agent.py @@ -625,7 +625,7 @@ class Agent: tools=available_tools, tool_choice=tool_choice, extra_headers={ - "User-Agent": get_user_agent(), + "user-agent": get_user_agent(provider.backend), "x-affinity": self.session_id, }, max_tokens=max_tokens, @@ -682,7 +682,7 @@ class Agent: tools=available_tools, tool_choice=tool_choice, extra_headers={ - "User-Agent": get_user_agent(), + "user-agent": get_user_agent(provider.backend), "x-affinity": self.session_id, }, max_tokens=max_tokens, @@ -903,6 +903,7 @@ class Agent: self.messages = [system_message, summary_message] active_model = self.config.get_active_model() + provider = self.config.get_provider_for_model(active_model) async with self.backend as backend: actual_context_tokens = await backend.count_tokens( @@ -911,7 +912,7 @@ class Agent: tools=self.format_handler.get_available_tools( self.tool_manager, self.config ), - extra_headers={"User-Agent": get_user_agent()}, + extra_headers={"user-agent": get_user_agent(provider.backend)}, ) self.stats.context_tokens = actual_context_tokens diff --git a/vibe/core/utils.py b/vibe/core/utils.py index 88ae667..5121cdd 100644 --- a/vibe/core/utils.py +++ b/vibe/core/utils.py @@ -14,7 +14,7 @@ from typing import Any import httpx from vibe.core import __version__ -from vibe.core.config import CONFIG_DIR, CONFIG_FILE, GLOBAL_CONFIG_FILE +from vibe.core.config import CONFIG_DIR, CONFIG_FILE, GLOBAL_CONFIG_FILE, Backend from vibe.core.types import BaseEvent, ToolResultEvent @@ -160,8 +160,12 @@ if CONFIG_FILE != GLOBAL_CONFIG_FILE and GLOBAL_CONFIG_FILE.is_file(): ) -def get_user_agent() -> str: - return f"Mistral-Vibe/{__version__}" +def get_user_agent(backend: Backend) -> str: + user_agent = f"Mistral-Vibe/{__version__}" + if backend == Backend.MISTRAL: + mistral_sdk_prefix = "mistral-client-python/" + user_agent = f"{mistral_sdk_prefix}{user_agent}" + return user_agent def _is_retryable_http_error(e: Exception) -> bool: