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

- Root cause: _handle_freebuff 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 a1920ad88e
commit cc4127f963
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: Freebuff streaming — SSE events now reach Codex client",
"Root cause: stream_buffered_events was never called for freebuff",
"Freebuff stream uses buffered flushing (30ms / 4KB / urgent)",
"Freebuff OAuth — built-in login flow (no external CLI needed)",
"Freebuff API: reverse-engineered www.codebuff.com endpoints",
"Freebuff session management with instance ID (waiting room)",
"Freebuff agent run lifecycle (start/finish) with model routing",
"Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
"Reasoning mode works with freebuff (thinking tokens supported)",
"GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
"GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
"GUI: Freebuff Login button in endpoint editor",
"Fixed _STATS undefined error in /health endpoint",
"Fixed freebuff credential path (reads default account)",
]),
("3.8.1", "2026-05-24", [
"Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
"Freebuff backend: auto agent-run lifecycle, credential detection, model routing",
@@ -321,6 +337,7 @@ PROVIDER_PRESETS = {
"Freebuff (Free DeepSeek/Kimi)": {
"backend_type": "freebuff",
"base_url": "https://freebuff.com",
"oauth_provider": "freebuff",
"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 == "freebuff":
self._oauth_btn.set_label("Freebuff Login")
self._entry_key.set_placeholder_text("Auto-filled by freebuff 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 == "freebuff":
self._freebuff_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 _freebuff_oauth_flow(self):
dlg = Gtk.Dialog(title="Freebuff 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 Freebuff</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 _freebuff_auth_thread():
try:
fingerprint_id = str(uuid.uuid4())
auth_url = "https://freebuff.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._freebuff_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://freebuff.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._freebuff_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._freebuff_oauth_done, dlg, spinner)
except Exception as e:
self._fb_oauth_result["error"] = str(e)[:200]
GLib.idle_add(self._freebuff_oauth_done, dlg, spinner)
threading.Thread(target=_freebuff_auth_thread, daemon=True).start()
dlg.connect("response", lambda d, r: d.destroy())
dlg.run()
def _freebuff_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("Freebuff 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://freebuff.com"
_FREEBUFF_AUTH_URL = "https://freebuff.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")
_freebuff_token_cache = {"token": None, "checked": 0}
_freebuff_session_cache = {"instance_id": None, "expires": 0, "model": None}
_freebuff_token_lock = threading.Lock()
def _get_freebuff_token():
@@ -304,7 +307,8 @@ def _get_freebuff_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 _freebuff_token_lock:
_freebuff_token_cache["token"] = token
_freebuff_token_cache["checked"] = time.time()
@@ -313,13 +317,42 @@ def _get_freebuff_token():
print(f"[freebuff] no credentials at {_FREEBUFF_CREDS_PATH}: {e}", file=sys.stderr)
return ""
def _freebuff_get_session(token, model):
with _freebuff_token_lock:
sc = _freebuff_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 _freebuff_token_lock:
_freebuff_session_cache["instance_id"] = instance_id
_freebuff_session_cache["expires"] = time.time() + min(expires_at / 1000, 3600)
_freebuff_session_cache["model"] = model
print(f"[freebuff] session active, instance={instance_id[:8]}...", file=sys.stderr)
return instance_id
return None
except Exception as e:
print(f"[freebuff] session failed: {e}", file=sys.stderr)
return None
def _freebuff_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 _freebuff_start_run(token, agent_id):
return None
def _freebuff_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 freebuff agent run. Check credentials and network."}})
instance_id = _freebuff_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["freebuff_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}] [freebuff] 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}] [freebuff] client disconnected", file=sys.stderr)
return
success = finish_reason != "length"
success = finish_reason[0] != "length"
_record_usage("freebuff", 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}] [freebuff] 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}] [freebuff] 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: