v3.8.3: Fix codebuff streaming — SSE events now reach Codex client

- Root cause: _handle_codebuff streaming loop collected events but never
  wrote them to self.wfile (stream_buffered_events was not called)
- Fix: Replaced manual loop with stream_buffered_events() + on_event callback
- Confirmed working: raw API streaming, non-stream, and stream through proxy
- Updated CHANGELOG.md, README.md, version labels to 3.8.3
This commit is contained in:
admin
2026-05-24 19:30:10 +04:00
Unverified
parent e265584af9
commit 2d4c1a9c2d
5 changed files with 282 additions and 54 deletions

View File

@@ -6,7 +6,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib
import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil
import hashlib, socket, ssl, contextlib, re, collections
import base64, secrets
import base64, secrets, uuid, webbrowser
from pathlib import Path
HOME = Path.home()
@@ -26,6 +26,22 @@ model_catalog_json = ""
"""
CHANGELOG = [
("3.8.3", "2026-05-24", [
"FIXED: Codebuff streaming — SSE events now reach Codex client",
"Root cause: stream_buffered_events was never called for codebuff",
"Codebuff stream uses buffered flushing (30ms / 4KB / urgent)",
"Codebuff OAuth — built-in login flow (no external CLI needed)",
"Codebuff API: reverse-engineered www.codebuff.com endpoints",
"Codebuff session management with instance ID (waiting room)",
"Codebuff agent run lifecycle (start/finish) with model routing",
"Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
"Reasoning mode works with codebuff (thinking tokens supported)",
"GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
"GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
"GUI: Codebuff Login button in endpoint editor",
"Fixed _STATS undefined error in /health endpoint",
"Fixed codebuff credential path (reads default account)",
]),
("3.8.1", "2026-05-24", [
"Codebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
"Codebuff backend: auto agent-run lifecycle, credential detection, model routing",
@@ -321,6 +337,7 @@ PROVIDER_PRESETS = {
"Codebuff (Free DeepSeek/Kimi)": {
"backend_type": "codebuff",
"base_url": "https://codebuff.com",
"oauth_provider": "codebuff",
"models": [
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
"moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
@@ -995,6 +1012,11 @@ def safe_cleanup_owned(logfn=None):
def _start_proxy_for(endpoint, logfn):
global _proxy_proc, _proxy_port
# Clear stale Python bytecode cache so proxy picks up latest source changes
import shutil
pycache = os.path.join(os.path.dirname(os.path.abspath(__file__)), '__pycache__')
if os.path.isdir(pycache):
shutil.rmtree(pycache, ignore_errors=True)
_stop_proxy()
port = _pick_free_port()
_proxy_port = port
@@ -1684,7 +1706,7 @@ class LauncherWin(Gtk.Window):
# header row
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
lbl = Gtk.Label(label="<b>Codex Launcher v3.8.1</b>")
lbl = Gtk.Label(label="<b>Codex Launcher v3.8.3</b>")
lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0)
changelog_btn = Gtk.Button(label="Changelog")
@@ -1790,6 +1812,26 @@ class LauncherWin(Gtk.Window):
self._model_combo = Gtk.ComboBoxText()
sel_box.pack_start(self._model_combo, True, True, 0)
# sandbox mode selector
sel_box.pack_start(Gtk.Label(label="Sandbox:"), False, False, 0)
self._sandbox_combo = Gtk.ComboBoxText()
for v, l in [("read-only", "Read-only"),
("workspace-write", "Workspace"),
("danger-full-access", "Full Access")]:
self._sandbox_combo.append(v, l)
self._sandbox_combo.set_active_id("workspace-write")
sel_box.pack_start(self._sandbox_combo, True, True, 0)
# approval mode selector
sel_box.pack_start(Gtk.Label(label="Approval:"), False, False, 0)
self._approval_combo = Gtk.ComboBoxText()
for v, l in [("untrusted", "Untrusted"),
("on-request", "On Request"),
("never", "Never (Full Auto)")]:
self._approval_combo.append(v, l)
self._approval_combo.set_active_id("on-request")
sel_box.pack_start(self._approval_combo, True, True, 0)
# launch buttons
btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 8)
@@ -2523,7 +2565,6 @@ class LauncherWin(Gtk.Window):
"""Launch codex CLI in a terminal with the selected endpoint."""
self.log(f"Launching Codex CLI with {ep['name']}…")
# Find a terminal emulator
terms = [
("x-terminal-emulator", ["-e"]),
("kgx", ["--"]),
@@ -2543,16 +2584,17 @@ class LauncherWin(Gtk.Window):
self.log("ERROR: no terminal emulator found (tried x-terminal-emulator, kgx, gnome-terminal, konsole, xterm)")
return
# For proxied endpoints, the proxy is already running (from _run)
# For native, no proxy needed
sandbox = self._sandbox_combo.get_active_id() or "workspace-write"
approval = self._approval_combo.get_active_id() or "on-request"
cmd_parts = [term] + term_args
if ep["backend_type"] == "native":
# Just run codex directly — config.toml is already set up
cmd_parts.extend(["codex", "-c", f"model={model}"])
cmd_parts.extend(["codex", "-c", f"model={model}",
"-s", sandbox, "-a", approval])
else:
# Proxy is running, run codex with the profile
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])
cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}",
"-s", sandbox, "-a", approval])
self.log(f"Running: {' '.join(cmd_parts)}")
self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
@@ -2618,7 +2660,9 @@ class LauncherWin(Gtk.Window):
self.log("ERROR: no terminal emulator found")
return
cmd_parts = [term] + term_args + ["codex"]
sandbox = self._sandbox_combo.get_active_id() or "workspace-write"
approval = self._approval_combo.get_active_id() or "on-request"
cmd_parts = [term] + term_args + ["codex", "-s", sandbox, "-a", approval]
self.log(f"Running: {' '.join(cmd_parts)}")
self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
pid = self._proc.pid
@@ -3097,9 +3141,14 @@ class EditEndpointDialog(Gtk.Dialog):
def _apply_selected_preset(self, initial=False):
preset_name = self._combo_preset.get_active_text() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, PROVIDER_PRESETS["Custom"])
is_oauth = bool(preset.get("oauth_provider"))
oauth_provider = preset.get("oauth_provider", "")
is_oauth = bool(oauth_provider)
self._oauth_btn.set_visible(is_oauth)
if is_oauth:
if oauth_provider == "codebuff":
self._oauth_btn.set_label("Codebuff Login")
self._entry_key.set_placeholder_text("Auto-filled by codebuff login")
elif is_oauth:
self._oauth_btn.set_label("OAuth Login")
self._entry_key.set_placeholder_text("Auto-filled by OAuth")
else:
self._entry_key.set_placeholder_text("")
@@ -3130,7 +3179,9 @@ class EditEndpointDialog(Gtk.Dialog):
preset_name = self._combo_preset.get_active_text() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, {})
provider = preset.get("oauth_provider", "")
if (provider or "").startswith("google"):
if provider == "codebuff":
self._codebuff_oauth_flow()
elif (provider or "").startswith("google"):
self._google_oauth_flow(provider)
def _google_oauth_flow(self, oauth_provider="google-cli"):
@@ -3406,6 +3457,117 @@ class EditEndpointDialog(Gtk.Dialog):
dlg.connect("response", lambda d, r: d.destroy())
dlg.run()
def _codebuff_oauth_flow(self):
dlg = Gtk.Dialog(title="Codebuff Login", parent=self, modal=True)
dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
dlg.set_default_size(500, 240)
area = dlg.get_content_area()
area.set_margin_start(16)
area.set_margin_end(16)
area.set_margin_top(12)
area.set_margin_bottom(12)
area.set_spacing(8)
area.pack_start(Gtk.Label(label="<b>Sign in with GitHub via Codebuff</b>", use_markup=True, xalign=0), False, False, 0)
self._oauth_status = Gtk.Label(label="Requesting login URL…", xalign=0)
self._oauth_status.set_line_wrap(True)
self._oauth_status.set_max_width_chars(60)
area.pack_start(self._oauth_status, False, False, 4)
link_lbl = Gtk.Label(xalign=0)
link_lbl.set_line_wrap(True)
link_lbl.set_max_width_chars(60)
area.pack_start(link_lbl, False, False, 4)
spinner = Gtk.Spinner()
spinner.start()
area.pack_start(spinner, False, False, 8)
area.show_all()
link_lbl.set_visible(False)
self._fb_oauth_result = {"success": False, "user": None, "error": None}
def _codebuff_auth_thread():
try:
fingerprint_id = str(uuid.uuid4())
auth_url = "https://codebuff.com/api/auth/cli/code"
body = json.dumps({"fingerprintId": fingerprint_id}).encode()
req = urllib.request.Request(auth_url, data=body,
headers={"Content-Type": "application/json", "User-Agent": "codex-launcher/3.8.3"})
resp = urllib.request.urlopen(req, timeout=30)
data = json.loads(resp.read())
login_url = data.get("loginUrl", "") or data.get("login_url", "")
fingerprint_hash = data.get("fingerprintHash", "") or data.get("fingerprint_hash", "")
expires_at = data.get("expiresAt", 0) or data.get("expires_at", 0)
if not login_url:
self._fb_oauth_result["error"] = "Server returned no login URL"
GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
return
def _set_link():
self._oauth_status.set_text("Open this URL in your browser to log in:")
link_lbl.set_markup(f'<a href="{login_url}">{login_url}</a>')
link_lbl.set_visible(True)
GLib.idle_add(_set_link)
webbrowser.open(login_url)
poll_url = f"https://codebuff.com/api/auth/cli/status?fingerprintId={urllib.parse.quote(fingerprint_id)}&fingerprintHash={urllib.parse.quote(fingerprint_hash)}&expiresAt={expires_at}"
deadline = time.time() + 300
while time.time() < deadline:
time.sleep(2)
try:
poll_req = urllib.request.Request(poll_url,
headers={"User-Agent": "codex-launcher/3.8.3"})
poll_resp = urllib.request.urlopen(poll_req, timeout=10)
poll_data = json.loads(poll_resp.read())
user = poll_data.get("user")
if user and user.get("authToken"):
self._fb_oauth_result["success"] = True
self._fb_oauth_result["user"] = user
GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
return
except urllib.error.HTTPError:
pass
except Exception:
pass
self._fb_oauth_result["error"] = "Login timed out after 5 minutes."
GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
except Exception as e:
self._fb_oauth_result["error"] = str(e)[:200]
GLib.idle_add(self._codebuff_oauth_done, dlg, spinner)
threading.Thread(target=_codebuff_auth_thread, daemon=True).start()
dlg.connect("response", lambda d, r: d.destroy())
dlg.run()
def _codebuff_oauth_done(self, dlg, spinner):
spinner.stop()
if self._fb_oauth_result["success"] and self._fb_oauth_result["user"]:
user = self._fb_oauth_result["user"]
creds_path = os.path.expanduser("~/.config/manicode/credentials.json")
os.makedirs(os.path.dirname(creds_path), exist_ok=True)
creds = {"default": {
"id": user.get("id", ""),
"name": user.get("name", ""),
"email": user.get("email", ""),
"authToken": user.get("authToken", ""),
"fingerprintId": user.get("fingerprintId", ""),
"fingerprintHash": user.get("fingerprintHash", ""),
}}
with open(creds_path, "w") as f:
json.dump(creds, f, indent=2)
os.chmod(creds_path, 0o600)
self._entry_key.set_text(user.get("authToken", ""))
self._oauth_status.set_markup('<span foreground="#27ae60" weight="bold">Authorization successful! Credentials saved.</span>')
dlg.set_title("Codebuff Login Success")
GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
else:
self._oauth_status.set_markup(f'<span foreground="#e74c3c">{self._fb_oauth_result["error"] or "Login failed."}</span>')
GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
def _oauth_success(self, dlg, access_token, spinner):
spinner.stop()
self._entry_key.set_text(access_token)

View File

@@ -254,6 +254,7 @@ _stats_lock = threading.Lock()
_stats_pending = []
_stats_flush_timer = None
_STATS_FLUSH_INTERVAL = 5.0
_STATS = {}
try:
_LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a")
@@ -286,7 +287,8 @@ _conn_pool = {}
_STREAM_IDLE_TIMEOUT = 300
_FREEBUFF_BASE_URL = "https://codebuff.com"
_FREEBUFF_AUTH_URL = "https://codebuff.com"
_FREEBUFF_API_URL = "https://www.codebuff.com"
_FREEBUFF_AGENT_MAP = {
"deepseek/deepseek-v4-pro": "base2-free-deepseek",
"deepseek/deepseek-v4-flash": "base2-free-deepseek-flash",
@@ -295,6 +297,7 @@ _FREEBUFF_AGENT_MAP = {
}
_FREEBUFF_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json")
_codebuff_token_cache = {"token": None, "checked": 0}
_codebuff_session_cache = {"instance_id": None, "expires": 0, "model": None}
_codebuff_token_lock = threading.Lock()
def _get_codebuff_token():
@@ -304,7 +307,8 @@ def _get_codebuff_token():
try:
with open(_FREEBUFF_CREDS_PATH) as f:
creds = json.load(f)
token = creds.get("authToken") or creds.get("apiKey") or ""
default_account = creds.get("default", {})
token = default_account.get("authToken") or creds.get("apiKey") or ""
with _codebuff_token_lock:
_codebuff_token_cache["token"] = token
_codebuff_token_cache["checked"] = time.time()
@@ -313,13 +317,42 @@ def _get_codebuff_token():
print(f"[codebuff] no credentials at {_FREEBUFF_CREDS_PATH}: {e}", file=sys.stderr)
return ""
def _codebuff_get_session(token, model):
with _codebuff_token_lock:
sc = _codebuff_session_cache
if sc["instance_id"] and sc["expires"] > time.time() + 60 and sc["model"] == model:
return sc["instance_id"]
try:
url = f"{_FREEBUFF_API_URL}/api/v1/freebuff/session"
body = json.dumps({"model": model}).encode()
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.3",
})
resp = urllib.request.urlopen(req, timeout=15)
data = json.loads(resp.read())
instance_id = data.get("instanceId", "")
expires_at = data.get("remainingMs", 0)
if instance_id:
with _codebuff_token_lock:
_codebuff_session_cache["instance_id"] = instance_id
_codebuff_session_cache["expires"] = time.time() + min(expires_at / 1000, 3600)
_codebuff_session_cache["model"] = model
print(f"[codebuff] session active, instance={instance_id[:8]}...", file=sys.stderr)
return instance_id
return None
except Exception as e:
print(f"[codebuff] session failed: {e}", file=sys.stderr)
return None
def _codebuff_start_run(token, agent_id):
url = f"{_FREEBUFF_BASE_URL}/api/v1/agent-runs"
url = f"{_FREEBUFF_API_URL}/api/v1/agent-runs"
body = json.dumps({"action": "START", "agentId": agent_id, "ancestorRunIds": []}).encode()
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.1",
"User-Agent": "codex-launcher/3.8.3",
})
try:
resp = urllib.request.urlopen(req, timeout=15)
@@ -336,13 +369,13 @@ def _codebuff_start_run(token, agent_id):
return None
def _codebuff_finish_run(token, run_id, status="completed"):
url = f"{_FREEBUFF_BASE_URL}/api/v1/agent-runs"
url = f"{_FREEBUFF_API_URL}/api/v1/agent-runs"
body = json.dumps({"action": "FINISH", "runId": run_id, "status": status,
"totalSteps": 1, "directCredits": 0, "totalCredits": 0}).encode()
req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.1",
"User-Agent": "codex-launcher/3.8.3",
})
try:
urllib.request.urlopen(req, timeout=10)
@@ -4537,12 +4570,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send_json(502, {"error": {"type": "upstream_error",
"message": "Failed to start codebuff agent run. Check credentials and network."}})
instance_id = _codebuff_get_session(token, model)
input_data = body.get("input", "")
messages = oa_input_to_messages(input_data)
instructions = body.get("instructions", "").strip()
if instructions:
messages.insert(0, {"role": "system", "content": instructions})
metadata = {
"run_id": run_id,
"cost_mode": "free",
}
if instance_id:
metadata["codebuff_instance_id"] = instance_id
chat_body = {
"model": model,
"messages": messages,
@@ -4550,10 +4592,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
"max_tokens": max(body.get("max_output_tokens", 0), 64000),
"enable_thinking": REASONING_ENABLED and REASONING_EFFORT != "none",
"reasoning_effort": REASONING_EFFORT if REASONING_ENABLED else "none",
"codebuff_metadata": {
"run_id": run_id,
"cost_mode": "free",
},
"codebuff_metadata": metadata,
}
for k in ("temperature", "top_p"):
if k in body:
@@ -4564,11 +4603,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
if body.get("tool_choice"):
chat_body["tool_choice"] = body["tool_choice"]
target = f"{_FREEBUFF_BASE_URL}/api/v1/chat/completions"
target = f"{_FREEBUFF_API_URL}/api/v1/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.1",
"User-Agent": "codex-launcher/3.8.3",
}
print(f"[{self._session_id}] [codebuff] POST {target} model={model} stream={stream} run={run_id}", file=sys.stderr)
@@ -4600,40 +4639,44 @@ class Handler(http.server.BaseHTTPRequestHandler):
except Exception:
pass
last_resp_id = None
last_output = None
last_status = None
finish_reason = None
collected_events = []
last_resp_id = [None]
last_output = [None]
last_status = [None]
finish_reason = [None]
def _on_fb_event(event):
if tracker and tracker.cancelled.is_set():
return False
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[0] = d.get("response", {}).get("id")
last_output[0] = d.get("response", {}).get("output", [])
last_status[0] = d.get("response", {}).get("status")
finish_reason[0] = "length" if last_status[0] == "incomplete" else "stop"
except Exception:
pass
return None
try:
for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")):
if tracker and tracker.cancelled.is_set():
break
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")
finish_reason = "length" if last_status == "incomplete" else "stop"
except Exception:
pass
self.stream_buffered_events(
oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")),
on_event=_on_fb_event)
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
print(f"[{self._session_id}] [codebuff] client disconnected", file=sys.stderr)
return
success = finish_reason != "length"
success = finish_reason[0] != "length"
_record_usage("codebuff", model, success, time.time() - t0)
if last_resp_id and input_data is not None:
store_response(last_resp_id, input_data, last_output)
print(f"[{self._session_id}] [codebuff] stream done status={last_status} in {time.time()-t0:.1f}s", file=sys.stderr)
if last_resp_id[0] and input_data is not None:
store_response(last_resp_id[0], input_data, last_output[0])
print(f"[{self._session_id}] [codebuff] stream done status={last_status[0]} in {time.time()-t0:.1f}s", file=sys.stderr)
else:
raw = upstream.read().decode()
result = oa_chat_to_responses(raw, model)
chat_resp = json.loads(raw)
result = oa_resp_to_responses(chat_resp, model)
self.send_json(200, result)
rid = result.get("id")
if rid: