diff --git a/src/openharness/api/openai_client.py b/src/openharness/api/openai_client.py index 94af3ce..6894ca1 100644 --- a/src/openharness/api/openai_client.py +++ b/src/openharness/api/openai_client.py @@ -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]: diff --git a/src/openharness/config/settings.py b/src/openharness/config/settings.py index 149b137..12c23e8 100644 --- a/src/openharness/config/settings.py +++ b/src/openharness/config/settings.py @@ -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) diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index a92277e..23ffe0e 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -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( diff --git a/src/openharness/ui/textual_app.py b/src/openharness/ui/textual_app.py index 2b0760c..c3ef02d 100644 --- a/src/openharness/ui/textual_app.py +++ b/src/openharness/ui/textual_app.py @@ -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 diff --git a/tests/test_api/test_openai_client.py b/tests/test_api/test_openai_client.py index 47ad862..e494561 100644 --- a/tests/test_api/test_openai_client.py +++ b/tests/test_api/test_openai_client.py @@ -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): diff --git a/tests/test_config/test_settings.py b/tests/test_config/test_settings.py index baf2ad6..3a7f8d6 100644 --- a/tests/test_config/test_settings.py +++ b/tests/test_config/test_settings.py @@ -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 diff --git a/tests/test_ui/test_textual_app.py b/tests/test_ui/test_textual_app.py index 18ca811..92d9107 100644 --- a/tests/test_ui/test_textual_app.py +++ b/tests/test_ui/test_textual_app.py @@ -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"]