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 - CLI
- ACP - ACP
- Both - Both
- None - Other
validations: validations:
required: true required: true

View file

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

2
.vscode/launch.json vendored
View file

@ -1,5 +1,5 @@
{ {
"version": "1.3.1", "version": "1.3.2",
"configurations": [ "configurations": [
{ {
"name": "ACP Server", "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/), 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).
## [1.3.2] - 2025-12-24
### Added
- User definable reasoning field
### Fixed
- Fix rendering issue with spinner
## [1.3.1] - 2025-12-24 ## [1.3.1] - 2025-12-24
### Fixed ### Fixed

View file

@ -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 = "1.3.1" version = "1.3.2"
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,25 +11,25 @@ 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/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" 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/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" 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/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" 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/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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [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" cmd = "./vibe-acp.exe"
[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/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" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "1.3.1" version = "1.3.2"
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"

View file

@ -41,7 +41,7 @@ class TestACPInitialize:
), ),
) )
assert response.agentInfo == Implementation( 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 == [] assert response.authMethods == []
@ -63,7 +63,7 @@ class TestACPInitialize:
), ),
) )
assert response.agentInfo == Implementation( 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 assert response.authMethods is not None

View file

@ -16,8 +16,8 @@ from vibe.core.config import (
SessionLoggingConfig, SessionLoggingConfig,
VibeConfig, VibeConfig,
) )
from vibe.core.llm.backend.generic import GenericBackend from vibe.core.llm.backend.generic import GenericBackend, OpenAIAdapter
from vibe.core.llm.backend.mistral import MistralMapper, ParsedContent from vibe.core.llm.backend.mistral import MistralBackend, MistralMapper, ParsedContent
from vibe.core.llm.format import APIToolFormatHandler from vibe.core.llm.format import APIToolFormatHandler
from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role from vibe.core.types import AssistantEvent, LLMMessage, ReasoningEvent, Role
@ -358,3 +358,175 @@ class TestLLMMessageReasoningContent:
dumped = msg.model_dump(exclude_none=True) dumped = msg.model_dump(exclude_none=True)
assert "reasoning_content" not in dumped 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]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "1.3.1" version = "1.3.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

View file

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

View file

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

View file

@ -129,6 +129,10 @@ class SpinnerMixin:
return return
self._indicator_widget.update(self._spinner.next_frame()) 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: def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False self._is_spinning = False
if self._spinner_timer: if self._spinner_timer:

View file

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

View file

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

View file

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

View file

@ -160,6 +160,13 @@ class MistralBackend:
else None 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 # Mistral SDK takes server URL without api version as input
url_pattern = r"(https?://[^/]+)(/v.*)" url_pattern = r"(https?://[^/]+)(/v.*)"
match = re.match(url_pattern, self._provider.api_base) match = re.match(url_pattern, self._provider.api_base)

View file

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