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:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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)
|
## v2.3.0 (2026-05-20)
|
||||||
|
|
||||||
- **Adaptive Crof self-healing system**
|
- **Adaptive Crof self-healing system**
|
||||||
|
|||||||
Binary file not shown.
BIN
codex-launcher_2.3.2_all.deb
Normal file
BIN
codex-launcher_2.3.2_all.deb
Normal file
Binary file not shown.
@@ -24,6 +24,14 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
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", [
|
("2.3.0", "2026-05-20", [
|
||||||
"Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
|
"Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
|
||||||
"Tracks per-model success/failure history, learns item count limits dynamically",
|
"Tracks per-model success/failure history, learns item count limits dynamically",
|
||||||
@@ -174,6 +182,25 @@ PROVIDER_PRESETS = {
|
|||||||
"base_url": "https://openrouter.ai/api/v1",
|
"base_url": "https://openrouter.ai/api/v1",
|
||||||
"models": [],
|
"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):
|
def safe_name(name):
|
||||||
@@ -455,6 +482,7 @@ def _start_proxy_for(endpoint, logfn):
|
|||||||
"target_url": normalize_base_url(endpoint["base_url"]),
|
"target_url": normalize_base_url(endpoint["base_url"]),
|
||||||
"api_key": endpoint["api_key"],
|
"api_key": endpoint["api_key"],
|
||||||
"cc_version": endpoint.get("cc_version", ""),
|
"cc_version": endpoint.get("cc_version", ""),
|
||||||
|
"oauth_provider": endpoint.get("oauth_provider", ""),
|
||||||
"reasoning_enabled": endpoint.get("reasoning_enabled", True),
|
"reasoning_enabled": endpoint.get("reasoning_enabled", True),
|
||||||
"reasoning_effort": endpoint.get("reasoning_effort", "medium"),
|
"reasoning_effort": endpoint.get("reasoning_effort", "medium"),
|
||||||
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
|
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
|
||||||
@@ -555,7 +583,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 v2.3.0</b>")
|
lbl = Gtk.Label(label="<b>Codex Launcher v2.3.2</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")
|
||||||
@@ -1451,7 +1479,13 @@ class EditEndpointDialog(Gtk.Dialog):
|
|||||||
|
|
||||||
self._entry_key = Gtk.Entry(text=self._data.get("api_key", ""))
|
self._entry_key = Gtk.Entry(text=self._data.get("api_key", ""))
|
||||||
self._entry_key.set_visibility(False)
|
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 = Gtk.Entry(text=self._data.get("cc_version", ""))
|
||||||
self._entry_cc_ver.set_placeholder_text("e.g. 0.26.8 (Command Code only)")
|
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):
|
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"))
|
||||||
|
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:
|
if not initial or self._existing_name is None:
|
||||||
self._combo_type.set_active_id(preset.get("backend_type", "openai-compat"))
|
self._combo_type.set_active_id(preset.get("backend_type", "openai-compat"))
|
||||||
self._entry_url.set_text(preset.get("base_url", ""))
|
self._entry_url.set_text(preset.get("base_url", ""))
|
||||||
@@ -1605,7 +1645,102 @@ class EditEndpointDialog(Gtk.Dialog):
|
|||||||
if initial and self._data.get("models"):
|
if initial and self._data.get("models"):
|
||||||
self._refresh_default_combo(self._data.get("default_model", ""))
|
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()
|
active = self._switch_reasoning.get_active()
|
||||||
self._combo_effort.set_sensitive(active)
|
self._combo_effort.set_sensitive(active)
|
||||||
if active:
|
if active:
|
||||||
@@ -1713,6 +1848,10 @@ class EditEndpointDialog(Gtk.Dialog):
|
|||||||
new_ep["cc_version"] = cc_ver
|
new_ep["cc_version"] = cc_ver
|
||||||
new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
|
new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
|
||||||
new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium"
|
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"])
|
new_ep["base_url"] = normalize_base_url(new_ep["base_url"])
|
||||||
|
|
||||||
# Update or append
|
# Update or append
|
||||||
|
|||||||
@@ -79,11 +79,47 @@ PORT = CONFIG["port"]
|
|||||||
BACKEND = CONFIG["backend_type"]
|
BACKEND = CONFIG["backend_type"]
|
||||||
TARGET_URL = CONFIG["target_url"].rstrip("/")
|
TARGET_URL = CONFIG["target_url"].rstrip("/")
|
||||||
API_KEY = CONFIG["api_key"]
|
API_KEY = CONFIG["api_key"]
|
||||||
|
OAUTH_PROVIDER = CONFIG.get("oauth_provider", "")
|
||||||
MODELS = CONFIG["models"]
|
MODELS = CONFIG["models"]
|
||||||
CC_VERSION = CONFIG.get("cc_version", "")
|
CC_VERSION = CONFIG.get("cc_version", "")
|
||||||
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
|
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
|
||||||
REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium")
|
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
|
# Shared helpers
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -1000,9 +1036,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
chat_body["reasoning_effort"] = REASONING_EFFORT
|
chat_body["reasoning_effort"] = REASONING_EFFORT
|
||||||
|
|
||||||
target = upstream_target(TARGET_URL, "/chat/completions")
|
target = upstream_target(TARGET_URL, "/chat/completions")
|
||||||
|
effective_key = _refresh_oauth_token()
|
||||||
fwd = forwarded_headers(self.headers, {
|
fwd = forwarded_headers(self.headers, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {API_KEY}",
|
"Authorization": f"Bearer {effective_key}",
|
||||||
}, browser_ua=True)
|
}, 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)
|
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user