This commit is contained in:
Clément Drouin 2025-12-24 16:04:45 +01:00 committed by Quentin
parent c79e2cf487
commit 08d8e85447
18 changed files with 287 additions and 51 deletions

View file

@ -18,7 +18,7 @@ body:
- CLI
- ACP
- Both
- None
- Other
validations:
required: true

View file

@ -18,7 +18,7 @@ body:
- CLI
- ACP
- Both
- None
- Other
validations:
required: true

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{
"version": "1.3.1",
"version": "1.3.2",
"configurations": [
{
"name": "ACP Server",

View file

@ -5,6 +5,16 @@ 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.3.2] - 2025-12-24
### Added
- User definable reasoning field
### Fixed
- Fix rendering issue with spinner
## [1.3.1] - 2025-12-24
### Fixed

View file

@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
version = "1.3.1"
version = "1.3.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.3.1/vibe-acp-darwin-aarch64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-darwin-aarch64-1.3.2.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-darwin-x86_64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-darwin-x86_64-1.3.2.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-linux-aarch64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-linux-aarch64-1.3.2.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-linux-x86_64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-linux-x86_64-1.3.2.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-windows-aarch64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-windows-aarch64-1.3.2.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.1/vibe-acp-windows-x86_64-1.3.1.zip"
archive = "https://github.com/mistralai/mistral-vibe/releases/download/v1.3.2/vibe-acp-windows-x86_64-1.3.2.zip"
cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
version = "1.3.1"
version = "1.3.2"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"

View file

@ -41,7 +41,7 @@ class TestACPInitialize:
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.2"
)
assert response.authMethods == []
@ -63,7 +63,7 @@ class TestACPInitialize:
),
)
assert response.agentInfo == Implementation(
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.1"
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="1.3.2"
)
assert response.authMethods is not None

View file

@ -16,8 +16,8 @@ from vibe.core.config import (
SessionLoggingConfig,
VibeConfig,
)
from vibe.core.llm.backend.generic import GenericBackend
from vibe.core.llm.backend.mistral import MistralMapper, ParsedContent
from vibe.core.llm.backend.generic import GenericBackend, OpenAIAdapter
from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper, ParsedContent
from vibe.core.llm.format import APIToolFormatHandler
from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role
@ -358,3 +358,175 @@ class TestLLMMessageReasoningContent:
dumped = msg.model_dump(exclude_none=True)
assert "reasoning_content" not in dumped
class TestReasoningFieldNameConversion:
def test_reasoning_to_api_keeps_default_field(self):
adapter = OpenAIAdapter()
msg_dict = {
"role": "assistant",
"content": "Answer",
"reasoning_content": "Thinking...",
}
result = adapter._reasoning_to_api(msg_dict, "reasoning_content")
assert result["reasoning_content"] == "Thinking..."
assert "reasoning" not in result
def test_reasoning_to_api_renames_to_custom_field(self):
adapter = OpenAIAdapter()
msg_dict = {
"role": "assistant",
"content": "Answer",
"reasoning_content": "Thinking...",
}
result = adapter._reasoning_to_api(msg_dict, "reasoning")
assert result["reasoning"] == "Thinking..."
assert "reasoning_content" not in result
def test_reasoning_from_api_converts_custom_field(self):
adapter = OpenAIAdapter()
msg_dict = {
"role": "assistant",
"content": "Answer",
"reasoning": "Thinking...",
}
result = adapter._reasoning_from_api(msg_dict, "reasoning")
assert result["reasoning_content"] == "Thinking..."
assert "reasoning" not in result
def test_reasoning_from_api_keeps_default_field(self):
adapter = OpenAIAdapter()
msg_dict = {
"role": "assistant",
"content": "Answer",
"reasoning_content": "Thinking...",
}
result = adapter._reasoning_from_api(msg_dict, "reasoning_content")
assert result["reasoning_content"] == "Thinking..."
@pytest.mark.asyncio
async def test_complete_with_custom_reasoning_field_name(self):
base_url = "https://api.example.com"
json_response = {
"id": "fake_id",
"created": 1234567890,
"model": "test-model",
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"object": "chat.completion",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "The answer is 42.",
"reasoning": "Let me think step by step...",
},
}
],
}
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="test",
api_base=f"{base_url}/v1",
api_key_env_var="API_KEY",
reasoning_field_name="reasoning",
)
backend = GenericBackend(provider=provider)
model = ModelConfig(name="test-model", provider="test", alias="test")
messages = [LLMMessage(role=Role.user, content="What is the answer?")]
result = await backend.complete(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
)
assert result.message.content == "The answer is 42."
assert result.message.reasoning_content == "Let me think step by step..."
@pytest.mark.asyncio
async def test_streaming_with_custom_reasoning_field_name(self):
base_url = "https://api.example.com"
chunks = [
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Thinking..."},"finish_reason":null}]}',
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}',
b'data: {"id":"id1","object":"chat.completion.chunk","created":123,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}',
b"data: [DONE]",
]
with respx.mock(base_url=base_url) as mock_api:
mock_api.post("/v1/chat/completions").mock(
return_value=httpx.Response(
status_code=200,
stream=httpx.ByteStream(stream=b"\n\n".join(chunks)),
headers={"Content-Type": "text/event-stream"},
)
)
provider = ProviderConfig(
name="test",
api_base=f"{base_url}/v1",
api_key_env_var="API_KEY",
reasoning_field_name="reasoning",
)
backend = GenericBackend(provider=provider)
model = ModelConfig(name="test-model", provider="test", alias="test")
messages = [LLMMessage(role=Role.user, content="Stream please")]
results = []
async for chunk in backend.complete_streaming(
model=model,
messages=messages,
temperature=0.2,
tools=None,
max_tokens=None,
tool_choice=None,
extra_headers=None,
):
results.append(chunk)
assert results[0].message.reasoning_content == "Thinking..."
assert results[1].message.content == "Answer"
class TestMistralReasoningFieldNameValidation:
def test_mistral_backend_rejects_custom_reasoning_field_name(self):
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
reasoning_field_name="reasoning",
)
with pytest.raises(ValueError) as exc_info:
MistralBackend(provider=provider)
assert "does not support custom reasoning_field_name" in str(exc_info.value)
assert "reasoning" in str(exc_info.value)
def test_mistral_backend_accepts_default_reasoning_field_name(self):
provider = ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
reasoning_field_name="reasoning_content",
)
backend = MistralBackend(provider=provider)
assert backend is not None

2
uv.lock generated
View file

@ -661,7 +661,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
version = "1.3.1"
version = "1.3.2"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "1.3.1"
__version__ = "1.3.2"

View file

@ -9,11 +9,12 @@ from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class LoadingWidget(Static):
class LoadingWidget(SpinnerMixin, Static):
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
SPINNER_TYPE = SpinnerType.BRAILLE
EASTER_EGGS: ClassVar[list[str]] = [
"Eating a chocolatine",
@ -50,12 +51,11 @@ class LoadingWidget(Static):
def __init__(self, status: str | None = None) -> None:
super().__init__(classes="loading-widget")
self.init_spinner()
self.status = status or self._get_default_status()
self.current_color_index = 0
self.transition_progress = 0
self._spinner = BrailleSpinner()
self.char_widgets: list[Static] = []
self.spinner_widget: Static | None = None
self.ellipsis_widget: Static | None = None
self.hint_widget: Static | None = None
self.start_time: float | None = None
@ -89,10 +89,10 @@ class LoadingWidget(Static):
def compose(self) -> ComposeResult:
with Horizontal(classes="loading-container"):
self.spinner_widget = Static(
self._indicator_widget = Static(
self._spinner.current_frame(), classes="loading-indicator"
)
yield self.spinner_widget
yield self._indicator_widget
with Horizontal(classes="loading-status"):
for char in self.status:
@ -120,12 +120,20 @@ class LoadingWidget(Static):
self.char_widgets.append(widget)
status_container.mount(widget)
self.update_animation()
self._update_animation()
def on_mount(self) -> None:
self.start_time = time()
self.update_animation()
self.set_interval(0.1, self.update_animation)
self._update_animation()
self.start_spinner_timer()
def on_resize(self) -> None:
self.refresh_spinner()
def _update_spinner_frame(self) -> None:
if not self._is_spinning:
return
self._update_animation()
def _get_color_for_position(self, position: int) -> str:
current_color = self.TARGET_COLORS[self.current_color_index]
@ -136,13 +144,13 @@ class LoadingWidget(Static):
return next_color
return current_color
def update_animation(self) -> None:
def _update_animation(self) -> None:
total_elements = 1 + len(self.char_widgets) + 2
if self.spinner_widget:
if self._indicator_widget:
spinner_char = self._spinner.next_frame()
color = self._get_color_for_position(0)
self.spinner_widget.update(f"[{color}]{spinner_char}[/]")
self._indicator_widget.update(f"[{color}]{spinner_char}[/]")
for i, widget in enumerate(self.char_widgets):
position = 1 + i

View file

@ -153,6 +153,9 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
def on_mount(self) -> None:
self.start_spinner_timer()
def on_resize(self) -> None:
self.refresh_spinner()
async def on_click(self) -> None:
await self._toggle_collapsed()

View file

@ -129,6 +129,10 @@ class SpinnerMixin:
return
self._indicator_widget.update(self._spinner.next_frame())
def refresh_spinner(self) -> None:
if self._indicator_widget:
self._indicator_widget.refresh()
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
if self._spinner_timer:

View file

@ -38,6 +38,9 @@ class StatusMessage(SpinnerMixin, Static):
self.update_display()
self.start_spinner_timer()
def on_resize(self) -> None:
self.refresh_spinner()
def _update_spinner_frame(self) -> None:
if not self._is_spinning:
return

View file

@ -131,6 +131,7 @@ class ProviderConfig(BaseModel):
api_key_env_var: str = ""
api_style: str = "openai"
backend: Backend = Backend.GENERIC
reasoning_field_name: str = "reasoning_content"
class _MCPBase(BaseModel):

View file

@ -46,7 +46,9 @@ class APIAdapter(Protocol):
api_key: str | None = None,
) -> PreparedRequest: ...
def parse_response(self, data: dict[str, Any]) -> LLMChunk: ...
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk: ...
BACKEND_ADAPTERS: dict[str, APIAdapter] = {}
@ -103,6 +105,20 @@ class OpenAIAdapter(APIAdapter):
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _reasoning_to_api(
self, msg_dict: dict[str, Any], field_name: str
) -> dict[str, Any]:
if field_name != "reasoning_content" and "reasoning_content" in msg_dict:
msg_dict[field_name] = msg_dict.pop("reasoning_content")
return msg_dict
def _reasoning_from_api(
self, msg_dict: dict[str, Any], field_name: str
) -> dict[str, Any]:
if field_name != "reasoning_content" and field_name in msg_dict:
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict
def prepare_request(
self,
*,
@ -116,7 +132,11 @@ class OpenAIAdapter(APIAdapter):
provider: ProviderConfig,
api_key: str | None = None,
) -> PreparedRequest:
converted_messages = [msg.model_dump(exclude_none=True) for msg in messages]
field_name = provider.reasoning_field_name
converted_messages = [
self._reasoning_to_api(msg.model_dump(exclude_none=True), field_name)
for msg in messages
]
payload = self.build_payload(
model_name, converted_messages, temperature, tools, max_tokens, tool_choice
@ -130,25 +150,37 @@ class OpenAIAdapter(APIAdapter):
payload["stream_options"] = stream_options
headers = self.build_headers(api_key)
body = json.dumps(payload).encode("utf-8")
return PreparedRequest(self.endpoint, headers, body)
def parse_response(self, data: dict[str, Any]) -> LLMChunk:
def _parse_message(
self, data: dict[str, Any], field_name: str
) -> LLMMessage | None:
if data.get("choices"):
if "message" in data["choices"][0]:
message = LLMMessage.model_validate(data["choices"][0]["message"])
elif "delta" in data["choices"][0]:
message = LLMMessage.model_validate(data["choices"][0]["delta"])
else:
raise ValueError("Invalid response data")
choice = data["choices"][0]
if "message" in choice:
msg_dict = self._reasoning_from_api(choice["message"], field_name)
return LLMMessage.model_validate(msg_dict)
if "delta" in choice:
msg_dict = self._reasoning_from_api(choice["delta"], field_name)
return LLMMessage.model_validate(msg_dict)
raise ValueError("Invalid response data: missing message or delta")
elif "message" in data:
message = LLMMessage.model_validate(data["message"])
elif "delta" in data:
message = LLMMessage.model_validate(data["delta"])
else:
if "message" in data:
msg_dict = self._reasoning_from_api(data["message"], field_name)
return LLMMessage.model_validate(msg_dict)
if "delta" in data:
msg_dict = self._reasoning_from_api(data["delta"], field_name)
return LLMMessage.model_validate(msg_dict)
return None
def parse_response(
self, data: dict[str, Any], provider: ProviderConfig
) -> LLMChunk:
message = self._parse_message(data, provider.reasoning_field_name)
if message is None:
message = LLMMessage(role=Role.assistant, content="")
usage_data = data.get("usage") or {}
@ -244,7 +276,7 @@ class GenericBackend:
try:
res_data, _ = await self._make_request(url, body, headers)
return adapter.parse_response(res_data)
return adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
raise BackendErrorBuilder.build_http_error(
@ -309,7 +341,7 @@ class GenericBackend:
try:
async for res_data in self._make_streaming_request(url, body, headers):
yield adapter.parse_response(res_data)
yield adapter.parse_response(res_data, self._provider)
except httpx.HTTPStatusError as e:
raise BackendErrorBuilder.build_http_error(

View file

@ -160,6 +160,13 @@ class MistralBackend:
else None
)
reasoning_field = getattr(provider, "reasoning_field_name", "reasoning_content")
if reasoning_field != "reasoning_content":
raise ValueError(
f"Mistral backend does not support custom reasoning_field_name "
f"(got '{reasoning_field}'). Mistral uses ThinkChunk for reasoning."
)
# Mistral SDK takes server URL without api version as input
url_pattern = r"(https?://[^/]+)(/v.*)"
match = re.match(url_pattern, self._provider.api_base)

View file

@ -179,15 +179,11 @@ class LLMMessage(BaseModel):
if isinstance(v, dict):
v.setdefault("content", "")
v.setdefault("role", "assistant")
v.setdefault(
"reasoning_content", v.get("reasoning_content") or v.get("reasoning")
)
return v
return {
"role": str(getattr(v, "role", "assistant")),
"content": getattr(v, "content", ""),
"reasoning_content": getattr(v, "reasoning_content", None)
or getattr(v, "reasoning", None),
"reasoning_content": getattr(v, "reasoning_content", None),
"tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None),