diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efdc3b..d56be91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v2.6.0 (2026-05-20) + +- **Usage Dashboard** — per-provider tracking with visual cards + - Request counts, success/failure rates, token usage, latency stats + - Color-coded success rate bars (green/yellow/red) + - Per-model breakdown showing request counts + - Last error and last used timestamp + - Sorted by most-used provider + - Refresh button for live updates +- **Proxy usage tracking** — records every request to `usage-stats.json` +- **Google OAuth**: browse for `client_secret.json` with file picker dialog + - No longer requires copying to a specific path manually + - Auto-copies selected file to `~/.cache/codex-proxy/` + ## v2.5.1 (2026-05-20) - **Adaptive retry for transient errors** (429/502/503) diff --git a/codex-launcher_2.5.1_all.deb b/codex-launcher_2.5.1_all.deb deleted file mode 100644 index 22ad5ac..0000000 Binary files a/codex-launcher_2.5.1_all.deb and /dev/null differ diff --git a/codex-launcher_2.6.0_all.deb b/codex-launcher_2.6.0_all.deb new file mode 100644 index 0000000..ee62bdf Binary files /dev/null and b/codex-launcher_2.6.0_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 9fd8e7c..19a9099 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -25,6 +25,11 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.6.0", "2026-05-20", [ + "Usage Dashboard — per-provider request/token/latency tracking", + "Visual cards with success rate bars, model breakdown, error tracking", + "Google OAuth: browse for client_secret.json instead of fixed path", + ]), ("2.5.1", "2026-05-20", [ "Adaptive retry for 429/502/503 errors with exponential backoff", "BGP routes also retry transient errors before failing over", @@ -635,12 +640,15 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v2.5.1") + lbl = Gtk.Label(label="Codex Launcher v2.6.0") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") changelog_btn.connect("clicked", lambda b: self._show_changelog()) hdr.pack_end(changelog_btn, False, False, 0) + usage_btn = Gtk.Button(label="Usage") + usage_btn.connect("clicked", lambda b: self._open_usage()) + hdr.pack_end(usage_btn, False, False, 0) bgp_btn = Gtk.Button(label="AI BGP") bgp_btn.connect("clicked", lambda b: self._open_bgp()) hdr.pack_end(bgp_btn, False, False, 0) @@ -942,6 +950,15 @@ class LauncherWin(Gtk.Window): d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") d.run(); d.destroy() + def _open_usage(self): + try: + self._usage_window = UsageWindow(self) + self._usage_window.connect("destroy", lambda *_: setattr(self, "_usage_window", None)) + except Exception as e: + import traceback; traceback.print_exc() + d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}") + d.run(); d.destroy() + def _backup_profile(self): chooser = Gtk.FileChooserDialog( title="Backup Codex Profile", @@ -1807,12 +1824,15 @@ class EditEndpointDialog(Gtk.Dialog): def _google_oauth_flow(self): token_path = os.path.expanduser("~/.cache/codex-proxy/google-oauth-token.json") - client_secret_path = os.path.expanduser("~/.cache/codex-proxy/client_secret.json") + default_cs_path = os.path.expanduser("~/.cache/codex-proxy/client_secret.json") + client_secret_path = None - if not os.path.exists(client_secret_path): + if os.path.exists(default_cs_path): + client_secret_path = default_cs_path + else: dlg = Gtk.Dialog(title="Google OAuth Setup", parent=self, modal=True) dlg.add_button("Open Google Console", 1) - dlg.add_button("I have client_secret.json", 2) + dlg.add_button("Browse for client_secret.json", 2) dlg.add_button("Cancel", Gtk.ResponseType.CANCEL) dlg.set_default_size(500, 320) area = dlg.get_content_area() @@ -1828,9 +1848,9 @@ class EditEndpointDialog(Gtk.Dialog): "2. APIs & Services → Credentials\n" " → Create OAuth 2.0 Client ID (Desktop app)\n" "3. Add redirect URI: http://localhost:8085\n" - "4. Download client_secret.json\n" - f"5. Save to: {client_secret_path}\n\n" - "Then click 'I have client_secret.json'" + "4. Download client_secret.json\n\n" + "Then click 'Browse for client_secret.json'\n" + f"It will be auto-copied to: {default_cs_path}" ), xalign=0) area.pack_start(steps, False, False, 4) area.show_all() @@ -1838,7 +1858,32 @@ class EditEndpointDialog(Gtk.Dialog): dlg.destroy() if r == 1: subprocess.Popen(["xdg-open", "https://console.cloud.google.com/apis/credentials"]) - return + return + if r == 2: + chooser = Gtk.FileChooserDialog( + title="Select client_secret.json", + parent=self, + action=Gtk.FileChooserAction.OPEN, + ) + chooser.add_button("Cancel", Gtk.ResponseType.CANCEL) + chooser.add_button("Open", Gtk.ResponseType.OK) + filt = Gtk.FileFilter() + filt.set_name("JSON files") + filt.add_pattern("*.json") + chooser.add_filter(filt) + chooser.set_current_folder(os.path.expanduser("~/Downloads")) + if chooser.run() == Gtk.ResponseType.OK: + src = chooser.get_filename() + chooser.destroy() + if src and os.path.exists(src): + import shutil as _shutil + os.makedirs(os.path.dirname(default_cs_path), exist_ok=True) + _shutil.copy2(src, default_cs_path) + client_secret_path = default_cs_path + else: + chooser.destroy() + if not client_secret_path: + return with open(client_secret_path) as f: cs = json.load(f) @@ -2465,6 +2510,197 @@ class BGPRouteDialog(Gtk.Dialog): self._combo_model.set_active(0) +_USAGE_COLORS = { + "green": "#27ae60", "yellow": "#f39c12", "orange": "#e67e22", + "red": "#e74c3c", "blue": "#3498db", "purple": "#9b59b6", + "dark": "#2c3e50", "light": "#ecf0f1", "mid": "#bdc3c7", +} + +_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json" + +def _load_usage_stats(): + try: + if _USAGE_STATS_FILE.exists(): + return json.loads(_USAGE_STATS_FILE.read_text()) + except Exception: + pass + return {"providers": {}, "updated": None} + +def _bar_color(pct): + if pct < 0.5: + return _USAGE_COLORS["green"] + if pct < 0.8: + return _USAGE_COLORS["yellow"] + return _USAGE_COLORS["red"] + +class UsageWindow(Gtk.Window): + def __init__(self, parent): + super().__init__(title="Usage Stats") + self.set_transient_for(parent) + self.set_default_size(640, 560) + self.set_position(Gtk.WindowPosition.CENTER) + self._parent = parent + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.add(vbox) + + header = Gtk.Box(spacing=8) + header.set_margin_start(16) + header.set_margin_end(16) + header.set_margin_top(12) + header.set_margin_bottom(8) + vbox.pack_start(header, False, False, 0) + title = Gtk.Label() + title.set_markup('Usage Dashboard') + header.pack_start(title, False, False, 0) + refresh_btn = Gtk.Button(label="Refresh") + refresh_btn.connect("clicked", lambda b: self._refresh()) + header.pack_end(refresh_btn, False, False, 0) + self._updated_lbl = Gtk.Label() + self._updated_lbl.set_markup('Never') + header.pack_end(self._updated_lbl, False, False, 8) + + sep = Gtk.Separator() + vbox.pack_start(sep, False, False, 0) + + self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sw.add(self._cards_box) + vbox.pack_start(sw, True, True, 0) + + self._refresh() + self.show_all() + + def _refresh(self): + for c in self._cards_box.get_children(): + self._cards_box.remove(c) + stats = _load_usage_stats() + updated = stats.get("updated") + if updated: + self._updated_lbl.set_markup(f'Updated: {updated}') + providers = stats.get("providers", {}) + if not providers: + empty = Gtk.Label() + empty.set_markup('No usage data yet.\nLaunch a session to start tracking.') + empty.set_margin_top(60) + self._cards_box.pack_start(empty, False, False, 0) + self._cards_box.show_all() + return + + sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True) + for prov_name, prov_data in sorted_providers: + card = self._build_card(prov_name, prov_data) + self._cards_box.pack_start(card, False, False, 0) + self._cards_box.show_all() + + def _build_card(self, name, data): + frame = Gtk.Frame() + frame.set_margin_start(12) + frame.set_margin_end(12) + frame.set_margin_top(4) + frame.set_margin_bottom(4) + style = frame.get_style_context() + style.add_class("card") + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + outer.set_margin_start(12) + outer.set_margin_end(12) + outer.set_margin_top(8) + outer.set_margin_bottom(8) + frame.add(outer) + + top_row = Gtk.Box(spacing=8) + outer.pack_start(top_row, False, False, 0) + + total = data.get("total_requests", 0) + ok = data.get("successes", 0) + fail = data.get("failures", 0) + success_rate = ok / total if total > 0 else 1.0 + + name_lbl = Gtk.Label() + short = name.replace("https://", "").replace("http://", "").split("/")[0] + name_lbl.set_markup(f'{short}') + top_row.pack_start(name_lbl, False, False, 0) + + req_lbl = Gtk.Label() + req_lbl.set_markup(f'{total} requests') + top_row.pack_start(req_lbl, False, False, 8) + + if fail > 0: + err_lbl = Gtk.Label() + err_lbl.set_markup(f'{fail} failed') + top_row.pack_start(err_lbl, False, False, 4) + + last_used = data.get("last_used", "") + if last_used: + lu_lbl = Gtk.Label() + lu_lbl.set_markup(f'{last_used}') + top_row.pack_end(lu_lbl, False, False, 0) + + # Progress bar for success rate + bar = Gtk.ProgressBar() + bar.set_fraction(success_rate) + bar_pct = int(success_rate * 100) + bar.set_text(f"{bar_pct}% success") + bar.set_show_text(True) + bar.set_margin_top(2) + bar.set_margin_bottom(2) + color = _bar_color(1.0 - success_rate) + bar_css = f'progress {{ background-color: {color}; border-radius: 4px; }} trough {{ border-radius: 4px; min-height: 10px; }}' + provider = Gtk.CssProvider() + provider.load_from_data(bar_css.encode()) + bar.get_style_context().add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + outer.pack_start(bar, False, False, 0) + + # Stats row + stats_row = Gtk.Box(spacing=16) + outer.pack_start(stats_row, False, False, 0) + + t_in = data.get("total_tokens_in", 0) + t_out = data.get("total_tokens_out", 0) + dur = data.get("total_duration_s", 0.0) + avg_dur = dur / total if total > 0 else 0 + + for label, value in [ + ("Tokens In", f"{t_in:,}"), + ("Tokens Out", f"{t_out:,}"), + ("Avg Latency", f"{avg_dur:.1f}s"), + ]: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + l = Gtk.Label() + l.set_markup(f'{label}') + box.pack_start(l, False, False, 0) + v = Gtk.Label() + v.set_markup(f'{value}') + box.pack_start(v, False, False, 0) + stats_row.pack_start(box, False, False, 0) + + # Models breakdown + models = data.get("models", {}) + if len(models) > 0: + model_str = " ".join( + f'{m} ' + f'({md.get("requests",0)})' + for m, md in sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)[:4] + ) + m_lbl = Gtk.Label() + m_lbl.set_markup(f'Models: {model_str}') + m_lbl.set_line_wrap(True) + m_lbl.set_xalign(0) + outer.pack_start(m_lbl, False, False, 2) + + # Error info + last_err = data.get("last_error") + if last_err: + err_lbl = Gtk.Label() + err_lbl.set_markup(f'Last error: {last_err}') + err_lbl.set_xalign(0) + outer.pack_start(err_lbl, False, False, 0) + + return frame + + def main(): for d in [LOG_DIR, PROXY_CONFIG_DIR]: d.mkdir(parents=True, exist_ok=True) diff --git a/src/translate-proxy.py b/src/translate-proxy.py index 80e4e69..1d2442e 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -141,6 +141,43 @@ _pool = uuid.uuid4().hex[:8] _response_store = {} _MAX_STORED = 50 +_stats_path = os.path.join(_LOG_DIR, "usage-stats.json") +_stats_lock = threading.Lock() + +def _load_stats(): + try: + if os.path.exists(_stats_path): + return json.load(open(_stats_path)) + except Exception: + pass + return {"providers": {}, "updated": None} + +def _record_usage(provider, model, success, duration_s, tokens_in=0, tokens_out=0, error_type=None): + with _stats_lock: + stats = _load_stats() + p = stats["providers"].setdefault(provider, { + "total_requests": 0, "successes": 0, "failures": 0, + "total_tokens_in": 0, "total_tokens_out": 0, + "total_duration_s": 0.0, "models": {}, "last_used": None, "last_error": None, + }) + p["total_requests"] += 1 + p["total_tokens_in"] += tokens_in + p["total_tokens_out"] += tokens_out + p["total_duration_s"] += duration_s + p["last_used"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + if success: + p["successes"] += 1 + else: + p["failures"] += 1 + p["last_error"] = error_type or "unknown" + m = p["models"].setdefault(model, {"requests": 0, "tokens_in": 0, "tokens_out": 0}) + m["requests"] += 1 + m["tokens_in"] += tokens_in + m["tokens_out"] += tokens_out + stats["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + with open(_stats_path, "w") as f: + json.dump(stats, f, indent=2) + def store_response(resp_id, input_data, output_items): if not resp_id: return @@ -1161,6 +1198,10 @@ class Handler(http.server.BaseHTTPRequestHandler): def _forward_oa_compat(self, upstream, stream, model, chat_body, body, input_data, fwd, target): n_items = len(input_data) if isinstance(input_data, list) else 1 + t0 = time.time() + provider = TARGET_URL.split("//")[-1].split("/")[0] + if BGP_ROUTES: + provider = "bgp:" + (BGP_ROUTES[0].get("name", "pool") if BGP_ROUTES else "unknown") if stream: self.send_response(200) @@ -1205,6 +1246,7 @@ class Handler(http.server.BaseHTTPRequestHandler): _log_resp(last_resp_id, last_status, last_output) if last_resp_id and input_data is not None: store_response(last_resp_id, input_data, last_output) + _record_usage(provider, model, success, time.time() - t0, error_type="length" if not success else None) # Auto-retry on finish_reason=length with no content if finish_reason == "length" and not has_content and isinstance(input_data, list) and len(input_data) > 5: @@ -1234,6 +1276,7 @@ class Handler(http.server.BaseHTTPRequestHandler): _log_resp(rid, result.get("status"), result.get("output", [])) if rid and input_data is not None: store_response(rid, input_data, result.get("output", [])) + _record_usage(provider, model, success, time.time() - t0) def _forward_oa_compat_retry(self, req, model, chat_body, body, input_data): try: