fix(ui,openai): reduce TUI flicker and make timeout configurable
This commit is contained in:
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
@@ -207,6 +208,22 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
|
||||
return ConversationMessage(role="assistant", content=content)
|
||||
|
||||
|
||||
def _normalize_openai_base_url(base_url: str | None) -> str | None:
|
||||
"""Normalize custom OpenAI-compatible base URLs without dropping API path segments."""
|
||||
if not base_url:
|
||||
return None
|
||||
trimmed = base_url.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
parts = urlsplit(trimmed)
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return trimmed.rstrip("/")
|
||||
path = parts.path.rstrip("/")
|
||||
if not path:
|
||||
path = "/v1"
|
||||
return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
class OpenAICompatibleClient:
|
||||
"""Client for OpenAI-compatible APIs (DashScope, GitHub Models, etc.).
|
||||
|
||||
@@ -214,10 +231,13 @@ class OpenAICompatibleClient:
|
||||
so it can be used as a drop-in replacement in the agent loop.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, *, base_url: str | None = None) -> None:
|
||||
def __init__(self, api_key: str, *, base_url: str | None = None, timeout: float | None = None) -> None:
|
||||
kwargs: dict[str, Any] = {"api_key": api_key}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
normalized_base_url = _normalize_openai_base_url(base_url)
|
||||
if normalized_base_url:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
self._client = AsyncOpenAI(**kwargs)
|
||||
|
||||
async def stream_message(self, request: ApiMessageRequest) -> AsyncIterator[ApiStreamEvent]:
|
||||
|
||||
@@ -415,6 +415,7 @@ class Settings(BaseModel):
|
||||
model: str = "claude-sonnet-4-6"
|
||||
max_tokens: int = 16384
|
||||
base_url: str | None = None
|
||||
timeout: float = 30.0
|
||||
api_format: str = "anthropic" # "anthropic", "openai", or "copilot"
|
||||
provider: str = ""
|
||||
active_profile: str = "claude-api"
|
||||
@@ -714,6 +715,10 @@ def _apply_env_overrides(settings: Settings) -> Settings:
|
||||
if max_tokens:
|
||||
updates["max_tokens"] = int(max_tokens)
|
||||
|
||||
timeout = os.environ.get("OPENHARNESS_TIMEOUT")
|
||||
if timeout:
|
||||
updates["timeout"] = float(timeout)
|
||||
|
||||
max_turns = os.environ.get("OPENHARNESS_MAX_TURNS")
|
||||
if max_turns:
|
||||
updates["max_turns"] = int(max_turns)
|
||||
|
||||
@@ -151,6 +151,7 @@ def _resolve_api_client_from_settings(settings) -> SupportsStreamingMessages:
|
||||
return OpenAICompatibleClient(
|
||||
api_key=auth.value,
|
||||
base_url=settings.base_url,
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
auth = _safe_resolve_auth()
|
||||
return AnthropicApiClient(
|
||||
|
||||
@@ -225,6 +225,10 @@ class OpenHarnessTerminalApp(App[None]):
|
||||
self._assistant_buffer = ""
|
||||
self._busy = False
|
||||
self.transcript_lines: list[str] = []
|
||||
self._last_status_snapshot: tuple[object, ...] | None = None
|
||||
self._last_tasks_snapshot: tuple[tuple[str, str, object, object], ...] | None = None
|
||||
self._last_mcp_summary: str | None = None
|
||||
self._last_current_response: str | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
@@ -253,8 +257,7 @@ class OpenHarnessTerminalApp(App[None]):
|
||||
)
|
||||
await start_runtime(self._bundle)
|
||||
self.query_one("#composer", Input).focus()
|
||||
self._refresh_sidebars()
|
||||
self.set_interval(1.0, self._refresh_sidebars)
|
||||
self._refresh_sidebars(force=True)
|
||||
if self._config.prompt:
|
||||
self.call_later(lambda: asyncio.create_task(self._process_line(self._config.prompt or "")))
|
||||
|
||||
@@ -382,7 +385,7 @@ class OpenHarnessTerminalApp(App[None]):
|
||||
self._refresh_sidebars()
|
||||
|
||||
def action_refresh_sidebars(self) -> None:
|
||||
self._refresh_sidebars()
|
||||
self._refresh_sidebars(force=True)
|
||||
|
||||
def action_toggle_vim(self) -> None:
|
||||
if self._bundle is None:
|
||||
@@ -416,38 +419,68 @@ class OpenHarnessTerminalApp(App[None]):
|
||||
self.transcript_lines.clear()
|
||||
|
||||
def _set_current_response(self, message: str) -> None:
|
||||
if message == self._last_current_response:
|
||||
return
|
||||
self.query_one("#current-response", Static).update(message)
|
||||
self._last_current_response = message
|
||||
|
||||
def _refresh_sidebars(self) -> None:
|
||||
def _refresh_sidebars(self, *, force: bool = False) -> None:
|
||||
if self._bundle is None:
|
||||
return
|
||||
state = self._bundle.app_state.get()
|
||||
usage = self._bundle.engine.total_usage
|
||||
status_lines = [
|
||||
"[b]Status[/b]",
|
||||
f"model: {state.model}",
|
||||
f"permissions: {state.permission_mode}",
|
||||
f"fast: {'on' if state.fast_mode else 'off'}",
|
||||
f"style: {state.output_style}",
|
||||
f"vim: {'on' if state.vim_enabled else 'off'}",
|
||||
f"voice: {'on' if state.voice_enabled else 'off'}",
|
||||
f"tokens: {usage.total_tokens}",
|
||||
f"messages: {len(self._bundle.engine.messages)}",
|
||||
]
|
||||
self.query_one("#status-bar", Static).update("\n".join(status_lines))
|
||||
status_snapshot = (
|
||||
state.model,
|
||||
state.permission_mode,
|
||||
state.fast_mode,
|
||||
state.output_style,
|
||||
state.vim_enabled,
|
||||
state.voice_enabled,
|
||||
usage.total_tokens,
|
||||
len(self._bundle.engine.messages),
|
||||
)
|
||||
if force or status_snapshot != self._last_status_snapshot:
|
||||
status_lines = [
|
||||
"[b]Status[/b]",
|
||||
f"model: {state.model}",
|
||||
f"permissions: {state.permission_mode}",
|
||||
f"fast: {'on' if state.fast_mode else 'off'}",
|
||||
f"style: {state.output_style}",
|
||||
f"vim: {'on' if state.vim_enabled else 'off'}",
|
||||
f"voice: {'on' if state.voice_enabled else 'off'}",
|
||||
f"tokens: {usage.total_tokens}",
|
||||
f"messages: {len(self._bundle.engine.messages)}",
|
||||
]
|
||||
self.query_one("#status-bar", Static).update("\n".join(status_lines))
|
||||
self._last_status_snapshot = status_snapshot
|
||||
|
||||
tasks = get_task_manager().list_tasks()
|
||||
if tasks:
|
||||
task_lines = ["[b]Tasks[/b]"]
|
||||
for task in tasks[:10]:
|
||||
suffix: list[str] = []
|
||||
if task.metadata.get("progress"):
|
||||
suffix.append(f"{task.metadata['progress']}%")
|
||||
if task.metadata.get("status_note"):
|
||||
suffix.append(task.metadata["status_note"])
|
||||
detail = f" ({' | '.join(suffix)})" if suffix else ""
|
||||
task_lines.append(f"{task.id} {task.status} {task.description}{detail}")
|
||||
else:
|
||||
task_lines = ["[b]Tasks[/b]", "No background tasks."]
|
||||
self.query_one("#tasks-panel", Static).update("\n".join(task_lines))
|
||||
self.query_one("#mcp-panel", Static).update(self._bundle.mcp_summary())
|
||||
tasks_snapshot = tuple(
|
||||
(
|
||||
task.id,
|
||||
task.status,
|
||||
task.metadata.get("progress"),
|
||||
task.metadata.get("status_note"),
|
||||
)
|
||||
for task in tasks[:10]
|
||||
)
|
||||
if force or tasks_snapshot != self._last_tasks_snapshot:
|
||||
if tasks:
|
||||
task_lines = ["[b]Tasks[/b]"]
|
||||
for task in tasks[:10]:
|
||||
suffix: list[str] = []
|
||||
if task.metadata.get("progress"):
|
||||
suffix.append(f"{task.metadata['progress']}%")
|
||||
if task.metadata.get("status_note"):
|
||||
suffix.append(task.metadata["status_note"])
|
||||
detail = f" ({' | '.join(suffix)})" if suffix else ""
|
||||
task_lines.append(f"{task.id} {task.status} {task.description}{detail}")
|
||||
else:
|
||||
task_lines = ["[b]Tasks[/b]", "No background tasks."]
|
||||
self.query_one("#tasks-panel", Static).update("\n".join(task_lines))
|
||||
self._last_tasks_snapshot = tasks_snapshot
|
||||
|
||||
mcp_summary = self._bundle.mcp_summary()
|
||||
if force or mcp_summary != self._last_mcp_summary:
|
||||
self.query_one("#mcp-panel", Static).update(mcp_summary)
|
||||
self._last_mcp_summary = mcp_summary
|
||||
|
||||
@@ -11,6 +11,7 @@ from openharness.api.openai_client import (
|
||||
OpenAICompatibleClient,
|
||||
_convert_messages_to_openai,
|
||||
_convert_tools_to_openai,
|
||||
_normalize_openai_base_url,
|
||||
_token_limit_param_for_model,
|
||||
)
|
||||
from openharness.engine.messages import (
|
||||
@@ -194,6 +195,17 @@ class TestConvertMessagesToOpenai:
|
||||
assert result[1]["tool_call_id"] == "c2"
|
||||
|
||||
|
||||
class TestNormalizeOpenAIBaseUrl:
|
||||
def test_preserves_explicit_v1_path(self):
|
||||
assert _normalize_openai_base_url("https://jarodfund.xyz/openai/v1") == "https://jarodfund.xyz/openai/v1"
|
||||
|
||||
def test_adds_default_v1_when_path_missing(self):
|
||||
assert _normalize_openai_base_url("https://api.example.com") == "https://api.example.com/v1"
|
||||
|
||||
def test_strips_trailing_slash_without_dropping_path(self):
|
||||
assert _normalize_openai_base_url("https://api.example.com/openai/v1/") == "https://api.example.com/openai/v1"
|
||||
|
||||
|
||||
class TestTokenLimitParams:
|
||||
def test_gpt5_uses_max_completion_tokens(self):
|
||||
assert _token_limit_param_for_model("gpt-5.4", 4096) == {"max_completion_tokens": 4096}
|
||||
@@ -236,6 +248,33 @@ class _FakeOpenAIClient:
|
||||
self.chat = _FakeChat()
|
||||
|
||||
|
||||
def test_openai_client_init_normalizes_base_url(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _StubAsyncOpenAI:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
|
||||
OpenAICompatibleClient(api_key="test-key", base_url="https://jarodfund.xyz/openai/v1/")
|
||||
|
||||
assert captured["base_url"] == "https://jarodfund.xyz/openai/v1"
|
||||
|
||||
|
||||
def test_openai_client_init_passes_timeout(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _StubAsyncOpenAI:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr("openharness.api.openai_client.AsyncOpenAI", _StubAsyncOpenAI)
|
||||
OpenAICompatibleClient(api_key="test-key", timeout=45.0)
|
||||
|
||||
assert captured["timeout"] == 45.0
|
||||
|
||||
|
||||
|
||||
class TestStreamMessageTokenParams:
|
||||
@pytest.mark.asyncio
|
||||
async def test_gpt5_stream_uses_max_completion_tokens(self):
|
||||
|
||||
@@ -26,6 +26,7 @@ class TestSettings:
|
||||
assert s.api_key == ""
|
||||
assert s.model == "claude-sonnet-4-6"
|
||||
assert s.max_tokens == 16384
|
||||
assert s.timeout == 30.0
|
||||
assert s.max_turns == 200
|
||||
assert s.fast_mode is False
|
||||
assert s.permission.mode == "default"
|
||||
@@ -382,6 +383,7 @@ def test_normalize_anthropic_model_name_matches_hermes_behavior():
|
||||
path.write_text(json.dumps({"model": "from-file", "base_url": "https://file.example"}))
|
||||
monkeypatch.setenv("ANTHROPIC_MODEL", "from-env-model")
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example/anthropic")
|
||||
monkeypatch.setenv("OPENHARNESS_TIMEOUT", "42.5")
|
||||
monkeypatch.setenv("OPENHARNESS_MAX_TURNS", "42")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-env-override")
|
||||
monkeypatch.setenv("OPENHARNESS_SANDBOX_ENABLED", "true")
|
||||
@@ -391,6 +393,7 @@ def test_normalize_anthropic_model_name_matches_hermes_behavior():
|
||||
|
||||
assert s.model == "from-env-model"
|
||||
assert s.base_url == "https://env.example/anthropic"
|
||||
assert s.timeout == 42.5
|
||||
assert s.max_turns == 42
|
||||
assert s.api_key == "sk-env-override"
|
||||
assert s.sandbox.enabled is True
|
||||
|
||||
@@ -114,3 +114,71 @@ async def test_textual_app_handles_ask_user_tool(tmp_path, monkeypatch):
|
||||
|
||||
assert any("tool-result> ask_user_question: green" in line for line in app.transcript_lines)
|
||||
assert any("assistant> chosen green" in line for line in app.transcript_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_textual_sidebar_refresh_is_snapshot_based(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
|
||||
app = OpenHarnessTerminalApp(api_client=StaticApiClient("hello"))
|
||||
async with app.run_test():
|
||||
status_bar = app.query_one("#status-bar")
|
||||
tasks_panel = app.query_one("#tasks-panel")
|
||||
|
||||
status_updates: list[str] = []
|
||||
task_updates: list[str] = []
|
||||
|
||||
original_status_update = status_bar.update
|
||||
original_task_update = tasks_panel.update
|
||||
|
||||
def _tracked_status_update(renderable):
|
||||
status_updates.append(str(renderable))
|
||||
return original_status_update(renderable)
|
||||
|
||||
def _tracked_task_update(renderable):
|
||||
task_updates.append(str(renderable))
|
||||
return original_task_update(renderable)
|
||||
|
||||
monkeypatch.setattr(status_bar, "update", _tracked_status_update)
|
||||
monkeypatch.setattr(tasks_panel, "update", _tracked_task_update)
|
||||
|
||||
app._refresh_sidebars()
|
||||
app._refresh_sidebars()
|
||||
assert status_updates == []
|
||||
assert task_updates == []
|
||||
|
||||
app._bundle.app_state.set(model="gpt-5.4")
|
||||
app._refresh_sidebars()
|
||||
assert len(status_updates) == 1
|
||||
assert task_updates == []
|
||||
|
||||
app._refresh_sidebars()
|
||||
assert len(status_updates) == 1
|
||||
assert task_updates == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_textual_current_response_update_is_deduplicated(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data"))
|
||||
|
||||
app = OpenHarnessTerminalApp(api_client=StaticApiClient("hello"))
|
||||
async with app.run_test():
|
||||
current_response = app.query_one("#current-response")
|
||||
updates: list[str] = []
|
||||
original_update = current_response.update
|
||||
|
||||
def _tracked_update(renderable):
|
||||
updates.append(str(renderable))
|
||||
return original_update(renderable)
|
||||
|
||||
monkeypatch.setattr(current_response, "update", _tracked_update)
|
||||
|
||||
app._set_current_response("same message")
|
||||
app._set_current_response("same message")
|
||||
app._set_current_response("next message")
|
||||
|
||||
assert updates == ["same message", "next message"]
|
||||
|
||||
Reference in New Issue
Block a user