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:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Run OpenAI Codex CLI & Desktop with <em>any</em> AI provider.</strong><br/>
|
<strong>Run OpenAI Codex CLI & Desktop with <em>any</em> AI provider.</strong><br/>
|
||||||
Google Antigravity • Gemini CLI • OpenCode • Z.AI • Anthropic • Command Code • OpenRouter • Crof.ai • NVIDIA NIM • Kilo.ai • DeepSeek • and more
|
Google Antigravity • Gemini CLI • OpenCode • Z.AI • Anthropic • Command Code • Freebuff • OpenRouter • Crof.ai • NVIDIA NIM • OpenAdapter • Kilo.ai • DeepSeek • 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)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
codex-launcher_3.8.3_all.deb
Normal file
BIN
codex-launcher_3.8.3_all.deb
Normal file
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user