diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ffab3..c3f5086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v2.3.2 (2026-05-20) + +- **Added Google Gemini provider with OAuth support** + - Two presets: "Google Gemini (API Key)" and "Google Gemini (OAuth)" + - OAuth Login button in endpoint editor — full Google OAuth2 flow + - Starts local HTTP server (port 8085), opens browser for Google consent + - Captures auth code, exchanges for access + refresh tokens + - Stores tokens in `~/.cache/codex-proxy/google-oauth-token.json` + - Auto-refreshes access tokens when expired (no manual re-login) + - Uses Gemini's OpenAI-compatible endpoint: `generativelanguage.googleapis.com/v1beta/openai` + - Models: gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, gemini-2.0-flash-lite, and more + - Setup instructions shown if `client_secret.json` not found + ## v2.3.0 (2026-05-20) - **Adaptive Crof self-healing system** diff --git a/codex-launcher_2.3.0_all.deb b/codex-launcher_2.3.0_all.deb deleted file mode 100644 index f633419..0000000 Binary files a/codex-launcher_2.3.0_all.deb and /dev/null differ diff --git a/codex-launcher_2.3.2_all.deb b/codex-launcher_2.3.2_all.deb new file mode 100644 index 0000000..2385cf9 Binary files /dev/null and b/codex-launcher_2.3.2_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index a11a5f1..18190ef 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -24,6 +24,14 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.3.2", "2026-05-20", [ + "Added Google Gemini provider with OAuth support", + "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'", + "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh", + "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)", + "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more", + "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy", + ]), ("2.3.0", "2026-05-20", [ "Adaptive Crof self-healing system — auto-adjusts to Crof model limits", "Tracks per-model success/failure history, learns item count limits dynamically", @@ -174,6 +182,25 @@ PROVIDER_PRESETS = { "base_url": "https://openrouter.ai/api/v1", "models": [], }, + "Google Gemini (API Key)": { + "backend_type": "openai-compat", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "models": [ + "gemini-2.5-flash", "gemini-2.5-pro", + "gemini-2.0-flash", "gemini-2.0-flash-lite", + "gemini-2.5-flash-preview-native-audio-dialog", + ], + }, + "Google Gemini (OAuth)": { + "backend_type": "openai-compat", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "oauth_provider": "google", + "models": [ + "gemini-2.5-flash", "gemini-2.5-pro", + "gemini-2.0-flash", "gemini-2.0-flash-lite", + "gemini-2.5-flash-preview-native-audio-dialog", + ], + }, } def safe_name(name): @@ -455,6 +482,7 @@ def _start_proxy_for(endpoint, logfn): "target_url": normalize_base_url(endpoint["base_url"]), "api_key": endpoint["api_key"], "cc_version": endpoint.get("cc_version", ""), + "oauth_provider": endpoint.get("oauth_provider", ""), "reasoning_enabled": endpoint.get("reasoning_enabled", True), "reasoning_effort": endpoint.get("reasoning_effort", "medium"), "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]} @@ -555,7 +583,7 @@ 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.3.0") + lbl = Gtk.Label(label="Codex Launcher v2.3.2") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") @@ -1451,7 +1479,13 @@ class EditEndpointDialog(Gtk.Dialog): self._entry_key = Gtk.Entry(text=self._data.get("api_key", "")) self._entry_key.set_visibility(False) - add_row(4, "API Key:", self._entry_key) + key_box = Gtk.Box(spacing=6) + key_box.pack_start(self._entry_key, True, True, 0) + self._oauth_btn = Gtk.Button(label="OAuth Login") + self._oauth_btn.connect("clicked", lambda b: self._do_oauth_login()) + key_box.pack_start(self._oauth_btn, False, False, 0) + add_row(4, "API Key:", key_box) + self._oauth_btn.set_visible(False) self._entry_cc_ver = Gtk.Entry(text=self._data.get("cc_version", "")) self._entry_cc_ver.set_placeholder_text("e.g. 0.26.8 (Command Code only)") @@ -1590,6 +1624,12 @@ 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")) + self._oauth_btn.set_visible(is_oauth) + if is_oauth: + self._entry_key.set_placeholder_text("Auto-filled by OAuth") + else: + self._entry_key.set_placeholder_text("") if not initial or self._existing_name is None: self._combo_type.set_active_id(preset.get("backend_type", "openai-compat")) self._entry_url.set_text(preset.get("base_url", "")) @@ -1605,7 +1645,102 @@ class EditEndpointDialog(Gtk.Dialog): if initial and self._data.get("models"): self._refresh_default_combo(self._data.get("default_model", "")) - def _on_reasoning_toggled(self, *_): + def _do_oauth_login(self): + preset_name = self._combo_preset.get_active_text() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + provider = preset.get("oauth_provider", "") + if provider == "google": + self._google_oauth_flow() + + 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") + + if not os.path.exists(client_secret_path): + d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, + "Google OAuth Setup\n\n" + "1. Go to https://console.cloud.google.com/\n" + "2. Create a project → Enable 'Generative Language API'\n" + "3. Go to APIs & Services → Credentials → Create OAuth 2.0 Client ID\n" + "4. Application type: Desktop app\n" + "5. Add redirect URI: http://localhost:8085\n" + "6. Download client_secret.json\n" + f"7. Save it to: {client_secret_path}\n\n" + "Then click OAuth Login again.") + d.run(); d.destroy() + return + + import http.server, threading, urllib.parse, json, urllib.request + + with open(client_secret_path) as f: + cs = json.load(f) + installed = cs.get("installed", cs.get("web", {})) + client_id = installed["client_id"] + client_secret = installed["client_secret"] + redirect_uri = "http://localhost:8085" + scope = "https://www.googleapis.com/auth/generative-language.retriever" + + auth_url = ( + f"https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={client_id}&redirect_uri={urllib.parse.quote(redirect_uri)}" + f"&response_type=code&scope={urllib.parse.quote(scope)}&access_type=offline&prompt=consent" + ) + + code_holder = [None] + class OAuthHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self2): + qs = urllib.parse.urlparse(self2.path).query + params = urllib.parse.parse_qs(qs) + if "code" in params: + code_holder[0] = params["code"][0] + self2.send_response(200) + self2.send_header("Content-Type", "text/html") + self2.end_headers() + self2.wfile.write(b"

Authorization successful! You can close this tab.

") + else: + self2.send_response(400) + self2.end_headers() + self2.wfile.write(b"Authorization failed.") + def log_message(self2, *a): pass + + server = http.server.HTTPServer(("127.0.0.1", 8085), OAuthHandler) + t = threading.Thread(target=server.handle_request, daemon=True) + t.start() + + os.system(f"xdg-open '{auth_url}' 2>/dev/null &") + + t.join(timeout=120) + server.server_close() + + if not code_holder[0]: + d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "OAuth timed out or failed.") + d.run(); d.destroy() + return + + token_data = urllib.parse.urlencode({ + "code": code_holder[0], + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }).encode() + req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + resp = urllib.request.urlopen(req, timeout=30) + tokens = json.loads(resp.read()) + tokens["client_id"] = client_id + tokens["client_secret"] = client_secret + tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600) + with open(token_path, "w") as f: + json.dump(tokens, f, indent=2) + self._entry_key.set_text(tokens.get("access_token", "")) + d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, + f"Google OAuth successful!\n\nAccess token saved.\nIt will auto-refresh when expired.") + d.run(); d.destroy() + except Exception as e: + d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Token exchange failed:\n{e}") + d.run(); d.destroy() active = self._switch_reasoning.get_active() self._combo_effort.set_sensitive(active) if active: @@ -1713,6 +1848,10 @@ class EditEndpointDialog(Gtk.Dialog): new_ep["cc_version"] = cc_ver new_ep["reasoning_enabled"] = self._switch_reasoning.get_active() new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium" + preset_name = self._combo_preset.get_active_text() or "Custom" + preset = PROVIDER_PRESETS.get(preset_name, {}) + if preset.get("oauth_provider"): + new_ep["oauth_provider"] = preset["oauth_provider"] new_ep["base_url"] = normalize_base_url(new_ep["base_url"]) # Update or append diff --git a/src/translate-proxy.py b/src/translate-proxy.py index f47aa9e..b5cbe43 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -79,11 +79,47 @@ PORT = CONFIG["port"] BACKEND = CONFIG["backend_type"] TARGET_URL = CONFIG["target_url"].rstrip("/") API_KEY = CONFIG["api_key"] +OAUTH_PROVIDER = CONFIG.get("oauth_provider", "") MODELS = CONFIG["models"] CC_VERSION = CONFIG.get("cc_version", "") REASONING_ENABLED = CONFIG.get("reasoning_enabled", True) REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium") +def _refresh_oauth_token(): + if OAUTH_PROVIDER != "google": + return API_KEY + token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "google-oauth-token.json") + if not os.path.exists(token_path): + return API_KEY + try: + with open(token_path) as f: + tokens = json.load(f) + if tokens.get("expires_at", 0) > time.time() + 60: + return tokens.get("access_token", API_KEY) + client_id = tokens.get("client_id", "") + client_secret = tokens.get("client_secret", "") + refresh_token = tokens.get("refresh_token", "") + if not all([client_id, client_secret, refresh_token]): + return tokens.get("access_token", API_KEY) + print("[oauth] refreshing Google access token...", file=sys.stderr) + data = urllib.parse.urlencode({ + "client_id": client_id, "client_secret": client_secret, + "refresh_token": refresh_token, "grant_type": "refresh_token", + }).encode() + req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + resp = urllib.request.urlopen(req, timeout=30) + new_tokens = json.loads(resp.read()) + tokens["access_token"] = new_tokens.get("access_token", tokens.get("access_token")) + tokens["expires_at"] = time.time() + new_tokens.get("expires_in", 3600) + with open(token_path, "w") as f: + json.dump(tokens, f, indent=2) + print("[oauth] token refreshed OK", file=sys.stderr) + return tokens["access_token"] + except Exception as e: + print(f"[oauth] refresh failed: {e}", file=sys.stderr) + return API_KEY + # ═══════════════════════════════════════════════════════════════════ # Shared helpers # ═══════════════════════════════════════════════════════════════════ @@ -1000,9 +1036,10 @@ class Handler(http.server.BaseHTTPRequestHandler): chat_body["reasoning_effort"] = REASONING_EFFORT target = upstream_target(TARGET_URL, "/chat/completions") + effective_key = _refresh_oauth_token() fwd = forwarded_headers(self.headers, { "Content-Type": "application/json", - "Authorization": f"Bearer {API_KEY}", + "Authorization": f"Bearer {effective_key}", }, browser_ua=True) print(f"[translate-proxy] POST {target} model={model} stream={stream} items={len(input_data) if isinstance(input_data,list) else 1} ua={fwd.get('User-Agent','')[:50]}", file=sys.stderr)