Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ 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.4.7] - 2026-06-15

### Fixed

- 🛡️ **Chat no longer breaks after crashes or interruptions.** If a previous session ended unexpectedly, leftover data could cause permanent errors when resuming a conversation. The chat now automatically cleans up mismatched data on load so you can always pick up where you left off.
- 🔄 **Better compatibility with OpenAI models.** Fixed issues where certain internal data was accidentally sent to OpenAI, causing requests to fail. Conversations with tool use now work reliably across all supported providers.
- 🪵 **Clearer error reporting for AI requests.** When an AI request fails, the error details are now logged properly instead of being silently swallowed, making issues easier to diagnose.

## [0.4.6] - 2026-06-15

### Added
Expand Down
24 changes: 21 additions & 3 deletions cptr/utils/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,14 +343,18 @@ async def stream_anthropic(


def _to_openai_messages(messages: list[dict], instructions: str) -> list[dict]:
"""Canonical messages → OpenAI Chat Completions format."""
"""Canonical messages → OpenAI Chat Completions format.

Strips non-standard fields (reasoning_items, fc_id in tool_calls)
that are used internally but would cause 400 errors from OpenAI.
"""
result = []
if instructions:
result.append({"role": "system", "content": instructions})
for m in messages:
if m["role"] == "system":
continue

content = m.get("content", "")
if isinstance(content, list):
formatted_content = []
Expand All @@ -368,9 +372,20 @@ def _to_openai_messages(messages: list[dict], instructions: str) -> list[dict]:
})
new_m = dict(m)
new_m["content"] = formatted_content
# Strip non-standard fields
new_m.pop("reasoning_items", None)
result.append(new_m)
else:
result.append(m)
out = dict(m)
# Strip non-standard fields that are only for Responses API
out.pop("reasoning_items", None)
# Clean tool_calls: remove fc_id which is Responses-API-only
if "tool_calls" in out:
out["tool_calls"] = [
{k: v for k, v in tc.items() if k != "fc_id"}
for tc in out["tool_calls"]
]
result.append(out)
return result


Expand Down Expand Up @@ -414,6 +429,9 @@ async def stream_openai_completions(
"POST", f"{url}/chat/completions", json=body, headers=headers
) as resp:
logger.info("[stream] openai completions status=%s", resp.status_code)
if resp.status_code >= 400:
error_body = await resp.aread()
logger.error("[stream] openai completions error body: %s", error_body.decode(errors="replace"))
resp.raise_for_status()
tool_calls: dict[int, dict] = {}
async for line in resp.aiter_lines():
Expand Down
83 changes: 79 additions & 4 deletions cptr/utils/chat_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,18 +669,26 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic
pass
else:
for ti, turn in enumerate(turns):
if turn["calls"]:
# Filter calls to only those with matching outputs
turn_output_ids = {o["call_id"] for o in turn["outputs"]}
matched_calls = [
tc for tc in turn["calls"]
if tc["id"] in turn_output_ids
]
if matched_calls:
if ti == 0:
# First turn: attach to the existing entry
entry["tool_calls"] = turn["calls"]
entry["tool_calls"] = matched_calls
if turn["reasoning"]:
entry["reasoning_items"] = turn["reasoning"]
else:
# Subsequent turns: create a new assistant entry
# Subsequent turns: flush the pending entry (last tool
# result from previous turn) before creating a new one.
result.append(entry)
entry = {
"role": "assistant",
"content": "",
"tool_calls": turn["calls"],
"tool_calls": matched_calls,
}
if turn["reasoning"]:
entry["reasoning_items"] = turn["reasoning"]
Expand All @@ -693,9 +701,76 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic
}

result.append(entry)

# ── Final sanitization: ensure every tool_call has a matching tool result ──
# This catches edge cases from compaction, DB corruption, or partial persistence.
result = _sanitize_tool_pairs(result)

return result, existing_summary


def _sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
"""Ensure every tool_call in an assistant message has a matching tool result.

Walks the message list and collects all tool result call_ids. Then
strips any tool_call entries from assistant messages that have no
matching result. Also removes orphaned tool-result messages.

This is the last line of defence against 400 errors from providers
that require strict tool_call ↔ tool_result pairing (OpenAI).
"""
# Collect all tool-result call_ids in the conversation
tool_result_ids = {
m["tool_call_id"]
for m in messages
if m.get("role") == "tool" and m.get("tool_call_id")
}

# Collect all tool_call ids declared by assistant messages
tool_call_ids = set()
for m in messages:
if m.get("role") == "assistant" and m.get("tool_calls"):
for tc in m["tool_calls"]:
tool_call_ids.add(tc.get("id", ""))

sanitized = []
for m in messages:
if m.get("role") == "assistant" and m.get("tool_calls"):
# Keep only tool_calls that have a matching tool result
kept = [tc for tc in m["tool_calls"] if tc.get("id") in tool_result_ids]
dropped = len(m["tool_calls"]) - len(kept)
if dropped:
logger.warning(
"[sanitize] Dropped %d orphaned tool_call(s) from assistant message",
dropped,
)
if kept:
m = dict(m)
m["tool_calls"] = kept
sanitized.append(m)
else:
# No tool_calls left — keep as plain text if there's content
m = dict(m)
del m["tool_calls"]
m.pop("reasoning_items", None)
if m.get("content"):
sanitized.append(m)
# else: drop empty assistant message entirely
elif m.get("role") == "tool":
# Drop tool results that have no matching tool_call
if m.get("tool_call_id") not in tool_call_ids:
logger.warning(
"[sanitize] Dropped orphaned tool result for call_id=%s",
m.get("tool_call_id", "?"),
)
continue
sanitized.append(m)
else:
sanitized.append(m)

return sanitized


def _parse_image_data_uri(result: str) -> tuple[str, str] | None:
"""Check if a tool result is a data URI image (from read_file on image files).

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "cptr"
version = "0.4.6"
version = "0.4.7"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"
Expand Down
Loading