v2.3.2: add Google Gemini provider with OAuth support

- Two presets: API Key and OAuth modes
- OAuth Login button: full Google OAuth2 flow with auto-refresh
- Auto-refreshes expired access tokens using refresh_token
- Gemini OpenAI-compatible endpoint works with existing proxy
- Models: gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, etc.
This commit is contained in:
Roman
2026-05-20 14:45:43 +04:00
Unverified
parent 27b22f4fd8
commit ea60d74527
5 changed files with 193 additions and 4 deletions

View File

@@ -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**

Binary file not shown.

Binary file not shown.

View File

@@ -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="<b>Codex Launcher v2.3.0</b>")
lbl = Gtk.Label(label="<b>Codex Launcher v2.3.2</b>")
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"<h1>Authorization successful! You can close this tab.</h1>")
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

View File

@@ -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)