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

@@ -1,6 +1,28 @@
# Changelog # Changelog
## v3.8.1 (2026-05-24) ## v3.8.3 (2026-05-24)
**Critical Fix — Freebuff Streaming Now Works End-to-End**
### Root Cause
The freebuff streaming handler collected SSE events into an internal list but **never wrote them to the client socket** (`self.wfile`). The `stream_buffered_events()` method — which handles buffered flushing (30ms interval / 4KB threshold / urgent events) — was not called for the freebuff streaming path. Codex CLI received zero bytes, showing "thinking..." indefinitely.
### Fix
Replaced the manual streaming loop in `_handle_freebuff()` with `self.stream_buffered_events()` using an `on_event` callback pattern, matching the architecture used by the gemini-oauth, anthropic, and command-code backends. Events now flow in real-time with proper buffered flushing.
### Changes
- **translate-proxy.py**: `_handle_freebuff()` streaming path rewritten — uses `stream_buffered_events()` with `_on_fb_event()` callback for metadata extraction
- Non-streaming path unchanged (already working)
- pycache cleanup in launcher ensures stale `.pyc` bytecode never loads old code
### Confirmed Working (API-level tests)
1. Raw freebuff API streaming: 36 SSE chunks, "hello" text received
2. Non-stream through proxy: complete JSON response with text
3. **Streaming through proxy: full SSE event sequence**`response.created``response.output_text.delta("hello")``response.completed`
---
## v3.8.2 (2026-05-24)
**Freebuff Integration — FREE DeepSeek V4 Pro Access + Provider Presets Restored** **Freebuff Integration — FREE DeepSeek V4 Pro Access + Provider Presets Restored**

View File

@@ -15,7 +15,7 @@
<p align="center"> <p align="center">
<strong>Run OpenAI Codex CLI &amp; Desktop with <em>any</em> AI provider.</strong><br/> <strong>Run OpenAI Codex CLI &amp; Desktop with <em>any</em> AI provider.</strong><br/>
Google Antigravity &bull; Gemini CLI &bull; OpenCode &bull; Z.AI &bull; Anthropic &bull; Command Code &bull; OpenRouter &bull; Crof.ai &bull; NVIDIA NIM &bull; Kilo.ai &bull; DeepSeek &bull; and more Google Antigravity &bull; Gemini CLI &bull; OpenCode &bull; Z.AI &bull; Anthropic &bull; Command Code &bull; Freebuff &bull; OpenRouter &bull; Crof.ai &bull; NVIDIA NIM &bull; OpenAdapter &bull; Kilo.ai &bull; DeepSeek &bull; and more
</p> </p>
<p align="center"> <p align="center">
@@ -544,9 +544,10 @@ The launcher generates model catalog JSON with dual field naming to satisfy both
| OpenCode Zen | OpenAI-compat | `https://opencode.ai/zen/v1` | | OpenCode Zen | OpenAI-compat | `https://opencode.ai/zen/v1` |
| OpenCode Go | OpenAI-compat | `https://opencode.ai/zen/go/v1` | | OpenCode Go | OpenAI-compat | `https://opencode.ai/zen/go/v1` |
| Command Code | Command Code | `https://api.commandcode.ai` | | Command Code | Command Code | `https://api.commandcode.ai` |
| **Freebuff** | **Freebuff** | `https://freebuff.com` *(free DeepSeek/Kimi)* | | **Freebuff** | **Freebuff** | `https://freebuff.com` *(free DeepSeek/Kimi — OAuth login built-in)* |
| Crof.ai | OpenAI-compat | `https://crof.ai/v1` | | Crof.ai | OpenAI-compat | `https://crof.ai/v1` |
| OpenAdapter | OpenAI-compat | `https://api.openadapter.in/v1` | | OpenAdapter | OpenAI-compat | `https://api.openadapter.in/v1` |
| Z.ai Coding | OpenAI-compat | `https://api.z.ai/api/coding/paas/v4` |
| NVIDIA NIM | OpenAI-compat | `https://integrate.api.nvidia.com/v1` | | NVIDIA NIM | OpenAI-compat | `https://integrate.api.nvidia.com/v1` |
| Kilo.ai | OpenAI-compat | `https://api.kilo.ai/api/gateway` | | Kilo.ai | OpenAI-compat | `https://api.kilo.ai/api/gateway` |
| OpenRouter | OpenAI-compat | `https://openrouter.ai/api/v1` | | OpenRouter | OpenAI-compat | `https://openrouter.ai/api/v1` |
@@ -563,7 +564,7 @@ Freebuff provides free access to these models — no API key needed:
- **Kimi K2.6** — Balanced - **Kimi K2.6** — Balanced
- **MiniMax M2.7** — Fastest - **MiniMax M2.7** — Fastest
*Requires: `npm install -g freebuff && freebuff login` (GitHub OAuth)* *Requires: `freebuff login` via GUI OAuth button, or `npm install -g freebuff && freebuff login` (GitHub OAuth)*
--- ---

Binary file not shown.

View File

@@ -6,7 +6,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib from gi.repository import Gtk, GLib
import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, urllib.error, tempfile, shutil
import hashlib, socket, ssl, contextlib, re, collections import hashlib, socket, ssl, contextlib, re, collections
import base64, secrets import base64, secrets, uuid, webbrowser
from pathlib import Path from pathlib import Path
HOME = Path.home() HOME = Path.home()
@@ -26,6 +26,22 @@ model_catalog_json = ""
""" """
CHANGELOG = [ 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", [ ("3.8.1", "2026-05-24", [
"Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7", "Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
"Freebuff backend: auto agent-run lifecycle, credential detection, model routing", "Freebuff backend: auto agent-run lifecycle, credential detection, model routing",
@@ -321,6 +337,7 @@ PROVIDER_PRESETS = {
"Freebuff (Free DeepSeek/Kimi)": { "Freebuff (Free DeepSeek/Kimi)": {
"backend_type": "freebuff", "backend_type": "freebuff",
"base_url": "https://freebuff.com", "base_url": "https://freebuff.com",
"oauth_provider": "freebuff",
"models": [ "models": [
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
"moonshotai/kimi-k2.6", "minimax/minimax-m2.7", "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
@@ -995,6 +1012,11 @@ def safe_cleanup_owned(logfn=None):
def _start_proxy_for(endpoint, logfn): def _start_proxy_for(endpoint, logfn):
global _proxy_proc, _proxy_port 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() _stop_proxy()
port = _pick_free_port() port = _pick_free_port()
_proxy_port = port _proxy_port = port
@@ -1684,7 +1706,7 @@ class LauncherWin(Gtk.Window):
# header row # header row
hdr = Gtk.Box(spacing=8) hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0) 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) lbl.set_use_markup(True)
hdr.pack_start(lbl, False, False, 0) hdr.pack_start(lbl, False, False, 0)
changelog_btn = Gtk.Button(label="Changelog") changelog_btn = Gtk.Button(label="Changelog")
@@ -1790,6 +1812,26 @@ class LauncherWin(Gtk.Window):
self._model_combo = Gtk.ComboBoxText() self._model_combo = Gtk.ComboBoxText()
sel_box.pack_start(self._model_combo, True, True, 0) 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 # launch buttons
btn_box = Gtk.Box(spacing=8, homogeneous=True) btn_box = Gtk.Box(spacing=8, homogeneous=True)
vbox.pack_start(btn_box, False, False, 8) 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.""" """Launch codex CLI in a terminal with the selected endpoint."""
self.log(f"Launching Codex CLI with {ep['name']}…") self.log(f"Launching Codex CLI with {ep['name']}…")
# Find a terminal emulator
terms = [ terms = [
("x-terminal-emulator", ["-e"]), ("x-terminal-emulator", ["-e"]),
("kgx", ["--"]), ("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)") self.log("ERROR: no terminal emulator found (tried x-terminal-emulator, kgx, gnome-terminal, konsole, xterm)")
return return
# For proxied endpoints, the proxy is already running (from _run) sandbox = self._sandbox_combo.get_active_id() or "workspace-write"
# For native, no proxy needed approval = self._approval_combo.get_active_id() or "on-request"
cmd_parts = [term] + term_args cmd_parts = [term] + term_args
if ep["backend_type"] == "native": 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: 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.log(f"Running: {' '.join(cmd_parts)}")
self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) 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") self.log("ERROR: no terminal emulator found")
return 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.log(f"Running: {' '.join(cmd_parts)}")
self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid) self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
pid = self._proc.pid pid = self._proc.pid
@@ -3097,9 +3141,14 @@ class EditEndpointDialog(Gtk.Dialog):
def _apply_selected_preset(self, initial=False): def _apply_selected_preset(self, initial=False):
preset_name = self._combo_preset.get_active_text() or "Custom" preset_name = self._combo_preset.get_active_text() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, PROVIDER_PRESETS["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) 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") self._entry_key.set_placeholder_text("Auto-filled by OAuth")
else: else:
self._entry_key.set_placeholder_text("") 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_name = self._combo_preset.get_active_text() or "Custom"
preset = PROVIDER_PRESETS.get(preset_name, {}) preset = PROVIDER_PRESETS.get(preset_name, {})
provider = preset.get("oauth_provider", "") 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) self._google_oauth_flow(provider)
def _google_oauth_flow(self, oauth_provider="google-cli"): 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.connect("response", lambda d, r: d.destroy())
dlg.run() 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): def _oauth_success(self, dlg, access_token, spinner):
spinner.stop() spinner.stop()
self._entry_key.set_text(access_token) self._entry_key.set_text(access_token)

View File

@@ -254,6 +254,7 @@ _stats_lock = threading.Lock()
_stats_pending = [] _stats_pending = []
_stats_flush_timer = None _stats_flush_timer = None
_STATS_FLUSH_INTERVAL = 5.0 _STATS_FLUSH_INTERVAL = 5.0
_STATS = {}
try: try:
_LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a") _LOG_FILE = open(os.path.join(_LOG_DIR, "proxy.log"), "a")
@@ -286,7 +287,8 @@ _conn_pool = {}
_STREAM_IDLE_TIMEOUT = 300 _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 = { _FREEBUFF_AGENT_MAP = {
"deepseek/deepseek-v4-pro": "base2-free-deepseek", "deepseek/deepseek-v4-pro": "base2-free-deepseek",
"deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", "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_CREDS_PATH = os.path.join(os.path.expanduser("~"), ".config", "manicode", "credentials.json")
_freebuff_token_cache = {"token": None, "checked": 0} _freebuff_token_cache = {"token": None, "checked": 0}
_freebuff_session_cache = {"instance_id": None, "expires": 0, "model": None}
_freebuff_token_lock = threading.Lock() _freebuff_token_lock = threading.Lock()
def _get_freebuff_token(): def _get_freebuff_token():
@@ -304,7 +307,8 @@ def _get_freebuff_token():
try: try:
with open(_FREEBUFF_CREDS_PATH) as f: with open(_FREEBUFF_CREDS_PATH) as f:
creds = json.load(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: with _freebuff_token_lock:
_freebuff_token_cache["token"] = token _freebuff_token_cache["token"] = token
_freebuff_token_cache["checked"] = time.time() _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) print(f"[freebuff] no credentials at {_FREEBUFF_CREDS_PATH}: {e}", file=sys.stderr)
return "" 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): 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() body = json.dumps({"action": "START", "agentId": agent_id, "ancestorRunIds": []}).encode()
req = urllib.request.Request(url, data=body, headers={ req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.1", "User-Agent": "codex-launcher/3.8.3",
}) })
try: try:
resp = urllib.request.urlopen(req, timeout=15) resp = urllib.request.urlopen(req, timeout=15)
@@ -336,13 +369,13 @@ def _freebuff_start_run(token, agent_id):
return None return None
def _freebuff_finish_run(token, run_id, status="completed"): 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, body = json.dumps({"action": "FINISH", "runId": run_id, "status": status,
"totalSteps": 1, "directCredits": 0, "totalCredits": 0}).encode() "totalSteps": 1, "directCredits": 0, "totalCredits": 0}).encode()
req = urllib.request.Request(url, data=body, headers={ req = urllib.request.Request(url, data=body, headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"User-Agent": "codex-launcher/3.8.1", "User-Agent": "codex-launcher/3.8.3",
}) })
try: try:
urllib.request.urlopen(req, timeout=10) urllib.request.urlopen(req, timeout=10)
@@ -4537,12 +4570,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send_json(502, {"error": {"type": "upstream_error", return self.send_json(502, {"error": {"type": "upstream_error",
"message": "Failed to start freebuff agent run. Check credentials and network."}}) "message": "Failed to start freebuff agent run. Check credentials and network."}})
instance_id = _freebuff_get_session(token, model)
input_data = body.get("input", "") input_data = body.get("input", "")
messages = oa_input_to_messages(input_data) messages = oa_input_to_messages(input_data)
instructions = body.get("instructions", "").strip() instructions = body.get("instructions", "").strip()
if instructions: if instructions:
messages.insert(0, {"role": "system", "content": 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 = { chat_body = {
"model": model, "model": model,
"messages": messages, "messages": messages,
@@ -4550,10 +4592,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
"max_tokens": max(body.get("max_output_tokens", 0), 64000), "max_tokens": max(body.get("max_output_tokens", 0), 64000),
"enable_thinking": REASONING_ENABLED and REASONING_EFFORT != "none", "enable_thinking": REASONING_ENABLED and REASONING_EFFORT != "none",
"reasoning_effort": REASONING_EFFORT if REASONING_ENABLED else "none", "reasoning_effort": REASONING_EFFORT if REASONING_ENABLED else "none",
"codebuff_metadata": { "codebuff_metadata": metadata,
"run_id": run_id,
"cost_mode": "free",
},
} }
for k in ("temperature", "top_p"): for k in ("temperature", "top_p"):
if k in body: if k in body:
@@ -4564,11 +4603,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
if body.get("tool_choice"): if body.get("tool_choice"):
chat_body["tool_choice"] = body["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 = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {token}", "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) 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: except Exception:
pass pass
last_resp_id = None last_resp_id = [None]
last_output = None last_output = [None]
last_status = None last_status = [None]
finish_reason = None finish_reason = [None]
collected_events = []
try: def _on_fb_event(event):
for event in oa_stream_to_sse(upstream, model, body.get("request_id") or body.get("id")):
if tracker and tracker.cancelled.is_set(): if tracker and tracker.cancelled.is_set():
break return False
collected_events.append(event)
for line in event.strip().split("\n"): for line in event.strip().split("\n"):
if line.startswith("data: "): if line.startswith("data: "):
try: try:
d = json.loads(line[6:]) d = json.loads(line[6:])
if d.get("type") == "response.completed": if d.get("type") == "response.completed":
last_resp_id = d.get("response", {}).get("id") last_resp_id[0] = d.get("response", {}).get("id")
last_output = d.get("response", {}).get("output", []) last_output[0] = d.get("response", {}).get("output", [])
last_status = d.get("response", {}).get("status") last_status[0] = d.get("response", {}).get("status")
finish_reason = "length" if last_status == "incomplete" else "stop" finish_reason[0] = "length" if last_status[0] == "incomplete" else "stop"
except Exception: except Exception:
pass pass
return None
try:
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): except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
print(f"[{self._session_id}] [freebuff] client disconnected", file=sys.stderr) print(f"[{self._session_id}] [freebuff] client disconnected", file=sys.stderr)
return return
success = finish_reason != "length" success = finish_reason[0] != "length"
_record_usage("freebuff", model, success, time.time() - t0) _record_usage("freebuff", model, success, time.time() - t0)
if last_resp_id and input_data is not None: if last_resp_id[0] and input_data is not None:
store_response(last_resp_id, input_data, last_output) store_response(last_resp_id[0], input_data, last_output[0])
print(f"[{self._session_id}] [freebuff] stream done status={last_status} in {time.time()-t0:.1f}s", file=sys.stderr) print(f"[{self._session_id}] [freebuff] stream done status={last_status[0]} in {time.time()-t0:.1f}s", file=sys.stderr)
else: else:
raw = upstream.read().decode() 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) self.send_json(200, result)
rid = result.get("id") rid = result.get("id")
if rid: if rid: