v1.3.2
This commit is contained in:
parent
c79e2cf487
commit
08d8e85447
18 changed files with 287 additions and 51 deletions
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -18,7 +18,7 @@ body:
|
||||||
- CLI
|
- CLI
|
||||||
- ACP
|
- ACP
|
||||||
- Both
|
- Both
|
||||||
- None
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
|
|
@ -18,7 +18,7 @@ body:
|
||||||
- CLI
|
- CLI
|
||||||
- ACP
|
- ACP
|
||||||
- Both
|
- Both
|
||||||
- None
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
|
||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "ACP Server",
|
"name": "ACP Server",
|
||||||
|
|
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue