diff --git a/CHANGELOG.md b/CHANGELOG.md index 39411b76..453e9377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.19] - 2026-07-31 + +### Changed + +- 🌐 **Open WebUI setup is clearer.** The gateway setup snippet now includes the user details Open WebUI can pass along, making connected chats easier to recognize. +- 🧰 **Action labels are easier to follow.** Connected chats and Claude Code now show names that better match the action being taken. +- 🤖 **Codex asks before taking more actions in Auto mode.** Auto approval now follows the same review path as ask-first sessions, so approval prompts appear more consistently. + +### Fixed + +- 💬 **Chats recover better when a model service is busy.** If a connection asks Computer to try again before anything has started, Computer now waits the requested time and retries instead of failing right away. +- 🧰 **Claude Code progress stays accurate.** Action details are kept together from start to finish, including when Claude Code only reports the final action. + ## [0.9.18] - 2026-07-31 ### Added diff --git a/cptr/frontend/src/lib/components/Admin/Gateway.svelte b/cptr/frontend/src/lib/components/Admin/Gateway.svelte index 180de3d6..6bee0307 100644 --- a/cptr/frontend/src/lib/components/Admin/Gateway.svelte +++ b/cptr/frontend/src/lib/components/Admin/Gateway.svelte @@ -30,6 +30,10 @@ /** Newly created key, shown once, then hidden */ let revealedKey = $state(''); const openWebUIHeaders = `{ + "X-OpenWebUI-User-Name": "{{USER_NAME}}", + "X-OpenWebUI-User-Id": "{{USER_ID}}", + "X-OpenWebUI-User-Email": "{{USER_EMAIL}}", + "X-OpenWebUI-User-Role": "{{USER_ROLE}}", "X-OpenWebUI-Chat-Id": "{{CHAT_ID}}", "X-OpenWebUI-Message-Id": "{{MESSAGE_ID}}", "X-OpenWebUI-User-Message-Id": "{{USER_MESSAGE_ID}}", diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index 48769805..f8e09070 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -67,7 +67,10 @@ def _format_tool_call(item: dict) -> str | None: if item.get("type") != "function_call" or item.get("status") != "in_progress": return None - return f"\n\n`{item.get('name', 'tool')}`\n\n" + arguments = item.get("arguments") + title = arguments.get("title") if isinstance(arguments, dict) else None + name = str(title or item.get("name") or "tool").strip() or "tool" + return f"\n\n`{name}`\n\n" async def _authenticate(request: Request) -> str: diff --git a/cptr/utils/agents/claude_code.py b/cptr/utils/agents/claude_code.py index 3f02b076..8c2a72d1 100644 --- a/cptr/utils/agents/claude_code.py +++ b/cptr/utils/agents/claude_code.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import os import shlex from typing import Any, AsyncIterator @@ -103,7 +104,7 @@ def _tool_update_from_claude_start( call_id=call_id.strip(), name="agent_tool", status="in_progress", - arguments={"title": name, **arguments}, + arguments={**arguments, "title": name}, ), ) @@ -188,6 +189,8 @@ async def run_claude_code_agent( usage: dict[str, Any] | None = None observed_session_id = session_id tool_calls: dict[int, AgentToolUpdate] = {} + tool_input_json: dict[int, str] = {} + received_tool_call_ids: set[str] = set() received_text_delta = False received_thinking_delta = False @@ -212,9 +215,18 @@ async def run_claude_code_agent( yield AgentReasoningDelta(thinking) if thinking: received_thinking_delta = True + elif delta.get("type") == "input_json_delta" and isinstance( + delta.get("partial_json"), str + ): + index = event.get("index") + if isinstance(index, int): + tool_input_json[index] = ( + tool_input_json.get(index, "") + delta["partial_json"] + ) elif event_type == "content_block_start": index, tool = _tool_update_from_claude_start(event) if tool: + received_tool_call_ids.add(tool.call_id) if index is not None: tool_calls[index] = tool yield tool @@ -222,11 +234,23 @@ async def run_claude_code_agent( index = event.get("index") tool = tool_calls.get(index) if isinstance(index, int) else None if tool: + arguments = dict(tool.arguments or {}) + title = str(arguments.pop("title", None) or "Claude action") + input_json = ( + tool_input_json.get(index) if isinstance(index, int) else None + ) + if input_json and input_json.strip(): + try: + parsed = json.loads(input_json) + if isinstance(parsed, dict): + arguments = parsed + except json.JSONDecodeError: + pass yield AgentToolUpdate( call_id=tool.call_id, name=tool.name, status="completed", - arguments=tool.arguments, + arguments={**arguments, "title": title}, output="", ) continue @@ -246,6 +270,28 @@ async def run_claude_code_agent( text = getattr(block, "thinking", "") if text: yield AgentReasoningDelta(text) + elif block.__class__.__name__ == "ToolUseBlock": + call_id = getattr(block, "id", None) + if ( + isinstance(call_id, str) + and call_id.strip() + and call_id not in received_tool_call_ids + ): + title = str( + getattr(block, "name", None) + or getattr(block, "type", None) + or "Claude action" + ).strip() + raw_input = getattr(block, "input", None) + arguments = raw_input if isinstance(raw_input, dict) else {} + received_tool_call_ids.add(call_id) + yield AgentToolUpdate( + call_id=call_id.strip(), + name="agent_tool", + status="completed", + arguments={**arguments, "title": title}, + output="", + ) elif class_name == "ResultMessage": observed_session_id = ( getattr(message, "session_id", None) or observed_session_id diff --git a/cptr/utils/agents/codex.py b/cptr/utils/agents/codex.py index 66e557a2..3f7c9c01 100644 --- a/cptr/utils/agents/codex.py +++ b/cptr/utils/agents/codex.py @@ -211,7 +211,7 @@ def _exit_message(self) -> str: def _approval_policy(value: str) -> str: - return {"ask": "on-request", "auto": "on-failure", "full": "never"}.get(value, "on-failure") + return {"ask": "on-request", "auto": "on-request", "full": "never"}.get(value, "on-request") def _chat_approval_mode(chat_params: dict[str, Any]) -> str: diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index b75733cd..aff87826 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -13,8 +13,10 @@ import copy import json import logging +import time import uuid from collections.abc import AsyncIterator +from email.utils import parsedate_to_datetime from typing import Dict, List import httpx @@ -51,6 +53,8 @@ def _openrouter_headers(url: str) -> dict[str, str]: _STREAM_RETRY_ATTEMPTS = 3 +_STREAM_RETRY_MAX_DELAY_SECONDS = 30 +_STREAM_RETRY_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504} _STREAM_TIMEOUT = httpx.Timeout( STREAM_CONNECT_TIMEOUT_SECONDS, read=STREAM_READ_TIMEOUT_SECONDS, @@ -59,6 +63,7 @@ def _openrouter_headers(url: str) -> dict[str, str]: _STREAM_RETRY_ERRORS = ( httpx.ConnectError, httpx.ConnectTimeout, + httpx.HTTPStatusError, httpx.ReadError, httpx.ReadTimeout, httpx.RemoteProtocolError, @@ -75,6 +80,32 @@ class ChatCompletionForm(BaseModel): tools: List[Dict] = [] +def _is_retryable_stream_error(exc: BaseException) -> bool: + if isinstance(exc, httpx.HTTPStatusError): + return exc.response.status_code in _STREAM_RETRY_STATUS_CODES + return isinstance(exc, _STREAM_RETRY_ERRORS) + + +def _stream_retry_delay(exc: BaseException, attempt: int) -> float: + delay = 0.5 * (attempt + 1) + if not isinstance(exc, httpx.HTTPStatusError): + return delay + + retry_after = exc.response.headers.get("retry-after") + if not retry_after: + return delay + + try: + delay = float(retry_after) + except ValueError: + try: + delay = parsedate_to_datetime(retry_after).timestamp() - time.time() + except (TypeError, ValueError, OverflowError): + return delay + + return min(max(delay, 0), _STREAM_RETRY_MAX_DELAY_SECONDS) + + # ── Non-streaming completion ──────────────────────────────── @@ -377,8 +408,8 @@ async def stream_anthropic( emitted = True yield {"type": "done"} return - except _STREAM_RETRY_ERRORS: - if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1: + except _STREAM_RETRY_ERRORS as exc: + if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc): raise logger.warning( "[stream] anthropic transient stream failure before first event; retrying (%s/%s)", @@ -386,7 +417,7 @@ async def stream_anthropic( _STREAM_RETRY_ATTEMPTS, exc_info=True, ) - await asyncio.sleep(0.5 * (attempt + 1)) + await asyncio.sleep(_stream_retry_delay(exc, attempt)) # ── OpenAI Chat Completions ────────────────────────────────── @@ -657,8 +688,8 @@ def complete_reasoning_item() -> dict | None: emitted = True yield {"type": "done"} return - except _STREAM_RETRY_ERRORS: - if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1: + except _STREAM_RETRY_ERRORS as exc: + if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc): raise logger.warning( "[stream] openai completions transient stream failure before first event; retrying (%s/%s)", @@ -666,7 +697,7 @@ def complete_reasoning_item() -> dict | None: _STREAM_RETRY_ATTEMPTS, exc_info=True, ) - await asyncio.sleep(0.5 * (attempt + 1)) + await asyncio.sleep(_stream_retry_delay(exc, attempt)) # ── OpenAI Responses API ───────────────────────────────────── @@ -974,8 +1005,8 @@ def get_reasoning_item(event: dict) -> dict: emitted = True yield {"type": "done"} return - except _STREAM_RETRY_ERRORS: - if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1: + except _STREAM_RETRY_ERRORS as exc: + if emitted or attempt == _STREAM_RETRY_ATTEMPTS - 1 or not _is_retryable_stream_error(exc): raise logger.warning( "[stream] openai responses transient stream failure before first event; retrying (%s/%s)", @@ -983,4 +1014,4 @@ def get_reasoning_item(event: dict) -> dict: _STREAM_RETRY_ATTEMPTS, exc_info=True, ) - await asyncio.sleep(0.5 * (attempt + 1)) + await asyncio.sleep(_stream_retry_delay(exc, attempt)) diff --git a/pyproject.toml b/pyproject.toml index 61425b37..0e395f06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.9.18" +version = "0.9.19" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index f2203de8..a40c5cef 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.9.18" +version = "0.9.19" source = { editable = "." } dependencies = [ { name = "aiosqlite" },