diff --git a/CHANGELOG.md b/CHANGELOG.md index eefa18d..17ffab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v2.3.0 (2026-05-20) + +- **Adaptive Crof self-healing system** + - Tracks per-model success/failure history with item counts + - Dynamically learns max item limit per model (starts at 30, adjusts down on failures) + - Proactively compacts input when above learned limit before sending to upstream + - Auto-retry on `finish_reason=length` with aggressive re-compaction and resend + - Prevents `stream disconnected` and `incomplete` errors on long conversations + - All tracking logged to stderr: `[crof-adaptive] model=X items=N OK/FAIL -> limit=N` +- Fixed `NameError: _ts` crash in debug logging +- Fixed `ConnectionResetError` crash on client disconnect during streaming +- Added 180s upstream timeout to prevent hanging connections +- Compaction now preserves function_call/function_call_output pairs (no orphaned tool outputs) +- Fixed reasoning control: `reasoning_effort=none` always sends both params + ## v2.2.1 (2026-05-20) - **Fixed compaction orphaning function_call_output items** — root cause of Crof `incomplete` responses diff --git a/codex-launcher_2.2.1_all.deb b/codex-launcher_2.2.1_all.deb deleted file mode 100644 index cd9970c..0000000 Binary files a/codex-launcher_2.2.1_all.deb and /dev/null differ diff --git a/codex-launcher_2.3.0_all.deb b/codex-launcher_2.3.0_all.deb new file mode 100644 index 0000000..f633419 Binary files /dev/null and b/codex-launcher_2.3.0_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 1757f72..a11a5f1 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -24,6 +24,13 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.3.0", "2026-05-20", [ + "Adaptive Crof self-healing system — auto-adjusts to Crof model limits", + "Tracks per-model success/failure history, learns item count limits dynamically", + "Proactively compacts input when above learned limit before sending to Crof", + "Auto-retries on finish_reason=length — aggressively compacts and resends", + "Prevents 'stream disconnected' and 'incomplete' errors on long conversations", + ]), ("2.2.1", "2026-05-20", [ "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses", "Compaction now respects function_call/function_call_output pairs — no more dangling tool results", @@ -548,7 +555,7 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v2.2.1") + lbl = Gtk.Label(label="Codex Launcher v2.3.0") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") diff --git a/src/translate-proxy.py b/src/translate-proxy.py index f3c5c44..f47aa9e 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -171,6 +171,87 @@ _MAX_INPUT_ITEMS = 30 _MAX_TOOL_OUTPUT_CHARS = 8000 _COMPACT_KEEP_RECENT = 10 +_CROF_ADAPTIVE = { + "fail_history": [], + "model_limits": {}, + "global_item_limit": 30, + "min_keep_recent": 4, +} + +def _crof_record(model, n_items, success): + if not isinstance(n_items, int) or n_items < 1: + return + entry = {"model": model, "items": n_items, "ok": success} + hist = _CROF_ADAPTIVE["fail_history"] + hist.append(entry) + if len(hist) > 200: + _CROF_ADAPTIVE["fail_history"] = hist[-100:] + + ml = _CROF_ADAPTIVE["model_limits"].setdefault(model, {"ok_max": 30, "fail_min": 0, "limit": 30}) + if success and n_items > ml["ok_max"]: + ml["ok_max"] = n_items + if not success and (ml["fail_min"] == 0 or n_items < ml["fail_min"]): + ml["fail_min"] = n_items + + if ml["fail_min"] > 0 and ml["ok_max"] >= ml["fail_min"]: + ml["limit"] = ml["fail_min"] - 1 + elif ml["fail_min"] > 0: + ml["limit"] = max(ml["fail_min"] - 2, _CROF_ADAPTIVE["min_keep_recent"] + 2) + + global_limit = 30 + for m, v in _CROF_ADAPTIVE["model_limits"].items(): + if v.get("limit", 30) < global_limit: + global_limit = v["limit"] + _CROF_ADAPTIVE["global_item_limit"] = global_limit + + print(f"[crof-adaptive] model={model} items={n_items} {'OK' if success else 'FAIL'} -> limit={ml.get('limit',30)} global={global_limit}", file=sys.stderr) + +def _crof_item_limit(model): + ml = _CROF_ADAPTIVE["model_limits"].get(model, {}) + per_model = ml.get("limit", 30) + return min(per_model, _CROF_ADAPTIVE["global_item_limit"]) + +def _crof_compact_for_retry(input_data, model): + limit = _crof_item_limit(model) + if not isinstance(input_data, list) or len(input_data) <= limit: + return input_data + + keep = max(_CROF_ADAPTIVE["min_keep_recent"], limit // 3) + head_end = 0 + for i, item in enumerate(input_data): + t = item.get("type") + if t == "message" and item.get("role") in ("developer", "system"): + head_end = i + 1 + elif t == "message" and item.get("role") == "user" and head_end == i: + head_end = i + 1 + else: + break + + head = input_data[:head_end] + tail_start = max(head_end, len(input_data) - keep) + while tail_start > head_end: + t = input_data[tail_start].get("type") + r = input_data[tail_start].get("role", "") + if t in ("function_call_output", "function_call"): + tail_start -= 1 + elif t == "message" and r == "assistant": + tail_start -= 1 + else: + break + tail = input_data[tail_start:] + body = input_data[head_end:tail_start] + + if not body: + return head + tail + + summary_lines = [f"[Auto-compacted: {len(body)} turns removed (adaptive limit={limit})]"] + for item in body[-5:]: + summary_lines.append(_item_summary(item, max_len=120)) + + summary_msg = {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "\n".join(summary_lines)}]} + print(f"[crof-adaptive] RETRY compact: {len(input_data)} -> {len(head)+1+len(tail)} (limit={limit}, keep={len(tail)})", file=sys.stderr) + return head + [summary_msg] + tail + def _item_summary(item, max_len=200): t = item.get("type") if t == "message": @@ -888,6 +969,15 @@ class Handler(http.server.BaseHTTPRequestHandler): def _handle_openai_compat(self, body, model, stream): input_data = body.get("input", "") + + # Adaptive: proactively compact if above learned Crof limit + crof_limit = _crof_item_limit(model) + if isinstance(input_data, list) and len(input_data) > crof_limit: + print(f"[crof-adaptive] proactive compact: {len(input_data)} items > limit {crof_limit}", file=sys.stderr) + input_data = _crof_compact_for_retry(input_data, model) + body = dict(body) + body["input"] = input_data + messages = oa_input_to_messages(input_data) instructions = body.get("instructions", "").strip() if instructions: @@ -914,25 +1004,136 @@ class Handler(http.server.BaseHTTPRequestHandler): "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", }, browser_ua=True) - print(f"[translate-proxy] POST {target} model={model} stream={stream} ua={fwd.get('User-Agent','')[:50]}", file=sys.stderr) - _crof_debug_path = os.path.join(_LOG_DIR, "crof-upstream.jsonl") - with open(_crof_debug_path, "a") as _cdf: - _cdf.write(json.dumps({ - "model": model, "max_tokens": chat_body.get("max_tokens"), - "reasoning_effort": chat_body.get("reasoning_effort"), - "enable_thinking": chat_body.get("enable_thinking", "NOT_SENT"), - "n_messages": len(chat_body.get("messages", [])), - "has_tools": bool(chat_body.get("tools")), - }) + "\n") + print(f"[translate-proxy] POST {target} model={model} stream={stream} items={len(input_data) if isinstance(input_data,list) else 1} ua={fwd.get('User-Agent','')[:50]}", file=sys.stderr) + req = urllib.request.Request( target, data=json.dumps(chat_body).encode(), headers=fwd, ) - self._forward(req, stream, model, - lambda r: oa_resp_to_responses(json.loads(r.read()), model), - lambda s: oa_stream_to_sse(s, model, body.get("request_id") or body.get("id")), - input_data=body.get("input", "")) + self._forward_oa_compat(req, stream, model, chat_body, body, input_data, fwd, target, tools) + + def _forward_oa_compat(self, req, stream, model, chat_body, body, input_data, fwd, target, tools): + try: + upstream = urllib.request.urlopen(req, timeout=180) + except urllib.error.HTTPError as e: + err = e.read().decode() + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) + except Exception as e: + return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + + n_items = len(input_data) if isinstance(input_data, list) else 1 + + if stream: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + + collected_events = [] + last_resp_id = None + last_output = None + last_status = None + finish_reason = None + has_content = False + + try: + for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")): + self.wfile.write(event.encode("utf-8")) + self.wfile.flush() + collected_events.append(event) + for line in event.strip().split("\n"): + if line.startswith("data: "): + try: + d = json.loads(line[6:]) + if d.get("type") == "response.completed": + last_resp_id = d.get("response", {}).get("id") + last_output = d.get("response", {}).get("output", []) + last_status = d.get("response", {}).get("status") + fr_map = {"completed": "stop", "incomplete": "length"} + finish_reason = "length" if last_status == "incomplete" else "stop" + has_content = any(o.get("type") == "message" for o in (last_output or [])) + except: pass + except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): + print("[translate-proxy] client disconnected during stream", file=sys.stderr) + _crof_record(model, n_items, False) + _log_resp(last_resp_id, "client_disconnect", last_output) + return + + # Record outcome + success = (finish_reason != "length") + _crof_record(model, n_items, success) + _log_resp(last_resp_id, last_status, last_output) + if last_resp_id and input_data is not None: + store_response(last_resp_id, input_data, last_output) + + # Auto-retry on finish_reason=length with no content + if finish_reason == "length" and not has_content and isinstance(input_data, list) and len(input_data) > 5: + print(f"[crof-adaptive] RETRY: finish_reason=length with no content, compacting {n_items} items", file=sys.stderr) + new_input = _crof_compact_for_retry(input_data, model) + if len(new_input) < len(input_data): + new_body = dict(body) + new_body["input"] = new_input + new_messages = oa_input_to_messages(new_input) + instructions = body.get("instructions", "").strip() + if instructions: + new_messages.insert(0, {"role": "system", "content": instructions}) + new_chat_body = dict(chat_body) + new_chat_body["messages"] = new_messages + new_req = urllib.request.Request( + target, + data=json.dumps(new_chat_body).encode(), + headers=fwd, + ) + self._forward_oa_compat_retry(new_req, model, new_chat_body, body, new_input) + else: + result = oa_resp_to_responses(json.loads(upstream.read()), model) + success = result.get("status") != "incomplete" + _crof_record(model, n_items, success) + self.send_json(200, result) + rid = result.get("id") + _log_resp(rid, result.get("status"), result.get("output", [])) + if rid and input_data is not None: + store_response(rid, input_data, result.get("output", [])) + + def _forward_oa_compat_retry(self, req, model, chat_body, body, input_data): + try: + upstream = urllib.request.urlopen(req, timeout=180) + except Exception as e: + print(f"[crof-adaptive] retry failed: {e}", file=sys.stderr) + return + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + + last_resp_id = None + last_output = None + last_status = None + try: + for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")): + self.wfile.write(event.encode("utf-8")) + self.wfile.flush() + for line in event.strip().split("\n"): + if line.startswith("data: "): + try: + d = json.loads(line[6:]) + if d.get("type") == "response.completed": + last_resp_id = d.get("response", {}).get("id") + last_output = d.get("response", {}).get("output", []) + last_status = d.get("response", {}).get("status") + except: pass + except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): + print("[translate-proxy] client disconnected during retry stream", file=sys.stderr) + + n_items = len(input_data) if isinstance(input_data, list) else 1 + _crof_record(model, n_items, last_status == "completed") + _log_resp(last_resp_id, last_status or "retry_disconnect", last_output) + if last_resp_id and input_data is not None: + store_response(last_resp_id, input_data, last_output) def _handle_anthropic(self, body, model, stream): input_data = body.get("input", "")