diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3c5d6..a47b8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,15 @@ ## v2.1.2 (2026-05-19) -- **Fixed Crof.ai and other providers stopping after first tool call (root cause)** -- Proxy now stores responses and resolves `previous_response_id` for multi-turn conversations -- Codex Desktop uses `previous_response_id` to chain turns — proxy reconstructs full conversation context -- Without this fix, the proxy sent only the new `function_call_output` to upstream without the original user message or assistant tool call, causing the upstream model to return incomplete responses +- **Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)** +- Codex sends `function_call` items with `id=None` — proxy now matches tool results to calls by call_id + positional fallback - Fixed orphan message output item when response is only tool calls (no text content) -- Response store capped at 50 entries (LRU eviction) +- **Auto-trims long conversations (>30 items)** to prevent context overflow on providers like Crof + - Keeps system/developer messages, original user query, and most recent items + - Drops oldest tool call/outputs from the middle when conversation grows too long + - Prevents `status=incomplete` errors on providers with smaller context windows +- Added request/response logging to `~/.cache/codex-proxy/requests.log` for debugging +- Proxy stderr no longer discarded by launcher (visible in terminal for debugging) ## v2.1.1 (2026-05-19) diff --git a/codex-launcher_2.1.2_all.deb b/codex-launcher_2.1.2_all.deb index 7a98692..4837cff 100644 Binary files a/codex-launcher_2.1.2_all.deb and b/codex-launcher_2.1.2_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 3f12e86..9753029 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -25,10 +25,11 @@ model_catalog_json = "" CHANGELOG = [ ("2.1.2", "2026-05-19", [ - "Fixed Crof.ai and other providers stopping after first tool call", - "Proxy now stores and resolves previous_response_id for multi-turn conversations", - "Codex Desktop uses previous_response_id to chain turns — proxy reconstructs full context", - "Fixed orphan message output item when response is only tool calls (no text)", + "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)", + "Codex sends function_call items with id=None — proxy now matches tool results to calls by position", + "Fixed orphan message output item when response has only tool calls (no text)", + "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof", + "Added request/response logging to ~/.cache/codex-proxy/requests.log", ]), ("2.1.1", "2026-05-19", [ "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers", @@ -437,7 +438,7 @@ def _start_proxy_for(endpoint, logfn): _proxy_proc = subprocess.Popen( ["python3", str(PROXY), "--config", str(pcfg_path)], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, preexec_fn=os.setsid, ) @@ -526,7 +527,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.0") + lbl = Gtk.Label(label="Codex Launcher v2.1.2") 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 f2cefa4..6ea3743 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -165,6 +165,28 @@ def forwarded_headers(request_headers, extra=None, browser_ua=False): headers.update(extra) return headers +_MAX_INPUT_ITEMS = 30 + +def _trim_input(input_data): + if not isinstance(input_data, list) or len(input_data) <= _MAX_INPUT_ITEMS: + return input_data + 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_keep = _MAX_INPUT_ITEMS - len(head) + tail = input_data[-tail_keep:] + trimmed = len(input_data) - len(head) - len(tail) + if trimmed > 0: + print(f"[trim] {len(input_data)} items -> {len(head) + len(tail)} (dropped {trimmed} old items)", file=sys.stderr) + return head + tail + # ═══════════════════════════════════════════════════════════════════ # OpenAI-compat backend # ═══════════════════════════════════════════════════════════════════ @@ -175,16 +197,19 @@ def oa_input_to_messages(input_data): msgs.append({"role": "user", "content": input_data}) elif isinstance(input_data, list): pending_tool_calls = [] + last_flushed_ids = [] for item in input_data: t = item.get("type") if t == "function_call": + tcid = item.get("call_id") or item.get("id") or uid("tc") pending_tool_calls.append( - {"id": item.get("call_id", item.get("id", uid("tc"))), + {"id": tcid, "type": "function", "function": {"name": item.get("name", ""), "arguments": item.get("arguments", "{}")}}) continue if pending_tool_calls: + last_flushed_ids = [tc["id"] for tc in pending_tool_calls] msgs.append({"role": "assistant", "content": None, "tool_calls": pending_tool_calls}) pending_tool_calls = [] if t == "message": @@ -205,7 +230,12 @@ def oa_input_to_messages(input_data): if text is not None: msgs.append({"role": role, "content": text}) elif t == "function_call_output": - msgs.append({"role": "tool", "tool_call_id": item.get("id", ""), + tcid = item.get("call_id") or item.get("id") or "" + if not tcid and last_flushed_ids: + idx = len([m for m in msgs if m.get("role") == "tool"]) + if idx < len(last_flushed_ids): + tcid = last_flushed_ids[idx] + msgs.append({"role": "tool", "tool_call_id": tcid, "content": item.get("output", "")}) if pending_tool_calls: msgs.append({"role": "assistant", "content": None, "tool_calls": pending_tool_calls}) @@ -654,6 +684,29 @@ def cc_stream_to_sse(cc_stream, model, req_id): # HTTP Server # ═══════════════════════════════════════════════════════════════════ +_LOG_DIR = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy") +os.makedirs(_LOG_DIR, exist_ok=True) + +def _log_resp(resp_id, status, output): + try: + import datetime as _dt + _lp = os.path.join(_LOG_DIR, "requests.log") + with open(_lp, "a") as _f: + _f.write(f" RESPONSE id={resp_id} status={status}\n") + if output: + for o in output: + ot = o.get("type") + if ot == "message": + _f.write(f" -> message: {o.get('content',[{}])[0].get('text','')[:200]}\n") + elif ot == "function_call": + _f.write(f" -> function_call: {o.get('name')}({o.get('arguments','')[:120]})\n") + else: + _f.write(f" -> {ot}\n") + _f.write(f"{'='*60}\n") + _f.flush() + except Exception: + pass + class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -669,6 +722,8 @@ class Handler(http.server.BaseHTTPRequestHandler): else: self.send_error(404) + _logf = None + def _handle(self): try: clen = int(self.headers.get("Content-Length", 0)) @@ -676,11 +731,39 @@ class Handler(http.server.BaseHTTPRequestHandler): except Exception as e: return self.send_json(400, {"error": {"message": f"Bad request: {e}"}}) - input_data = resolve_previous_response(body) - body["input"] = input_data + import datetime as _dt + _log_path = os.path.join(_LOG_DIR, "requests.log") + _ts = _dt.datetime.now().isoformat() + prev_id = body.get("previous_response_id") - input_types = [i.get("type") for i in input_data] if isinstance(input_data, list) else str(type(input_data)) - print(f"[REQUEST] prev_id={prev_id} resolved_input_types={input_types}", file=sys.stderr) + raw_input = body.get("input", "") + input_data = resolve_previous_response(body) + input_data = _trim_input(input_data) + body["input"] = input_data + + raw_types = [i.get("type") for i in raw_input] if isinstance(raw_input, list) else "str" + resolved_types = [i.get("type") for i in input_data] if isinstance(input_data, list) else "str" + + print(f"[REQUEST] prev_id={prev_id} raw={raw_types} resolved={resolved_types}", file=sys.stderr) + with open(_log_path, "a") as _lf: + _lf.write(f"\n{'='*60}\n{_ts} REQUEST {self.path}\n") + _lf.write(f" prev_id={prev_id}\n") + _lf.write(f" raw_input_types={raw_types}\n") + _lf.write(f" resolved_input_types={resolved_types}\n") + _lf.write(f" stream={body.get('stream')} model={body.get('model')}\n") + _lf.write(f" store_keys={list(_response_store.keys())}\n") + if isinstance(input_data, list): + for i, item in enumerate(input_data): + t = item.get("type") + if t == "message": + _lf.write(f" [{i}] message role={item.get('role')} text={str(item.get('content',''))[:120]}\n") + elif t == "function_call": + _lf.write(f" [{i}] function_call call_id={item.get('call_id')} id={item.get('id')} name={item.get('name')} args={item.get('arguments','')[:120]}\n") + elif t == "function_call_output": + _lf.write(f" [{i}] function_call_output id={item.get('id')} output={str(item.get('output',''))[:120]}\n") + else: + _lf.write(f" [{i}] {t}\n") + _lf.flush() model = body.get("model", MODELS[0]["id"] if MODELS else "unknown") stream = body.get("stream", False) @@ -887,6 +970,7 @@ class Handler(http.server.BaseHTTPRequestHandler): self.end_headers() last_resp_id = None last_output = None + last_status = None for event in stream_fn(upstream): self.wfile.write(event.encode("utf-8")) self.wfile.flush() @@ -897,13 +981,16 @@ class Handler(http.server.BaseHTTPRequestHandler): 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 + _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) else: result = nonstream_fn(upstream) 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", []))