v3.3.0: Antigravity OAuth + Gemini CLI OAuth, full Codex agent loop with tool calls, history hardening, SSE fixes
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib
|
||||
import subprocess, os, signal, sys, threading, time, json, urllib.request, tempfile, shutil
|
||||
import subprocess, os, signal, sys, threading, time, json, urllib.request, urllib.parse, tempfile, shutil
|
||||
import hashlib, socket, contextlib
|
||||
import base64, secrets
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
@@ -25,6 +26,21 @@ model_catalog_json = ""
|
||||
"""
|
||||
|
||||
CHANGELOG = [
|
||||
("3.3.0", "2026-05-20", [
|
||||
"Added Google Antigravity OAuth backend with Code Assist endpoints and model alias mapping",
|
||||
"Added Gemini CLI OAuth backend using public Gemini CLI OAuth client",
|
||||
"Antigravity now creates files via tool calls — full Codex agent loop with Gemini-style history hardening",
|
||||
"Fixed tool-call streaming: function_call_arguments delta/done events, thought signatures, functionResponse name matching",
|
||||
"Added Endpoint Doctor, adaptive BGP scoring, provider policies, adaptive compaction, log redaction",
|
||||
]),
|
||||
("3.1.0", "2026-05-20", [
|
||||
"Initial Antigravity/Gemini CLI OAuth split, history hardening, SSE fixes",
|
||||
]),
|
||||
("3.0.0", "2026-05-20", [
|
||||
"ThreadingHTTPServer with dynamic proxy ports and health-gated Codex launch",
|
||||
"Atomic config writes, safe cleanup registry, graceful shutdown, and buffered SSE streaming",
|
||||
"Usage Dashboard v2, TCP_NODELAY streaming, Anthropic prompt caching, and batched usage stats",
|
||||
]),
|
||||
("2.6.1", "2026-05-20", [
|
||||
"Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed",
|
||||
"Uses Google's public OAuth client_id (same as gemini-cli)",
|
||||
@@ -226,13 +242,25 @@ PROVIDER_PRESETS = {
|
||||
],
|
||||
},
|
||||
"Google Gemini (OAuth)": {
|
||||
"backend_type": "openai-compat",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"oauth_provider": "google",
|
||||
"backend_type": "gemini-oauth-cli",
|
||||
"base_url": "https://cloudcode-pa.googleapis.com",
|
||||
"oauth_provider": "google-cli",
|
||||
"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 Antigravity (OAuth)": {
|
||||
"backend_type": "gemini-oauth-antigravity",
|
||||
"base_url": "https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"oauth_provider": "google-antigravity",
|
||||
"models": [
|
||||
"antigravity-gemini-3-flash",
|
||||
"antigravity-gemini-3-pro",
|
||||
"antigravity-gemini-3.1-pro",
|
||||
"antigravity-claude-sonnet-4-6",
|
||||
"antigravity-claude-opus-4-6-thinking",
|
||||
"gemini-2.5-flash", "gemini-2.5-pro",
|
||||
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
||||
],
|
||||
},
|
||||
"OpenAdapter": {
|
||||
@@ -301,8 +329,10 @@ def apply_provider_preset(endpoint, preset_name):
|
||||
updated["base_url"] = normalize_base_url(preset["base_url"])
|
||||
if preset.get("cc_version") and not updated.get("cc_version"):
|
||||
updated["cc_version"] = preset["cc_version"]
|
||||
if not updated.get("models"):
|
||||
if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"):
|
||||
updated["models"] = list(preset.get("models", []))
|
||||
if preset.get("oauth_provider"):
|
||||
updated["oauth_provider"] = preset["oauth_provider"]
|
||||
if not updated.get("default_model") and updated.get("models"):
|
||||
updated["default_model"] = updated["models"][0]
|
||||
return updated
|
||||
@@ -630,6 +660,18 @@ def _start_proxy_for(endpoint, logfn):
|
||||
port = _pick_free_port()
|
||||
_proxy_port = port
|
||||
|
||||
model_list = endpoint.get("models", [])
|
||||
if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"):
|
||||
token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json"
|
||||
token_path = os.path.expanduser(f"~/.cache/codex-proxy/{token_name}")
|
||||
try:
|
||||
with open(token_path) as tf:
|
||||
td = json.load(tf)
|
||||
discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
|
||||
if discovered:
|
||||
model_list = discovered
|
||||
except Exception:
|
||||
pass
|
||||
pcfg = {
|
||||
"port": port,
|
||||
"backend_type": endpoint["backend_type"],
|
||||
@@ -640,7 +682,7 @@ def _start_proxy_for(endpoint, logfn):
|
||||
"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"]}
|
||||
for m in endpoint.get("models", [])],
|
||||
for m in model_list],
|
||||
}
|
||||
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
|
||||
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -763,7 +805,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 v3.0.0</b>")
|
||||
lbl = Gtk.Label(label="<b>Codex Launcher v3.3.0</b>")
|
||||
lbl.set_use_markup(True)
|
||||
hdr.pack_start(lbl, False, False, 0)
|
||||
changelog_btn = Gtk.Button(label="Changelog")
|
||||
@@ -1277,7 +1319,7 @@ class LauncherWin(Gtk.Window):
|
||||
self.log("ERROR: no model selected")
|
||||
return
|
||||
|
||||
is_bgp = name.startswith("🔀 ")
|
||||
is_bgp = bool(name and name.startswith("🔀 "))
|
||||
if is_bgp:
|
||||
pool_name = name[2:]
|
||||
pool = None
|
||||
@@ -1781,6 +1823,8 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
for val, lab in [("openai-compat", "OpenAI-compatible (needs proxy)"),
|
||||
("anthropic", "Anthropic (needs proxy)"),
|
||||
("command-code", "Command Code (needs proxy)"),
|
||||
("gemini-oauth-cli", "Gemini CLI OAuth (needs proxy)"),
|
||||
("gemini-oauth-antigravity", "Antigravity OAuth (needs proxy)"),
|
||||
("native", "Native OpenAI (no proxy)")]:
|
||||
self._combo_type.append(val, lab)
|
||||
bt = self._data.get("backend_type", "openai-compat")
|
||||
@@ -1866,6 +1910,9 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
self._fetch_models_btn = Gtk.Button(label="Fetch from API")
|
||||
self._fetch_models_btn.connect("clicked", lambda b: self._fetch_models())
|
||||
mbox.pack_start(self._fetch_models_btn, False, False, 0)
|
||||
self._test_btn = Gtk.Button(label="Test Endpoint")
|
||||
self._test_btn.connect("clicked", lambda b: self._diagnose_endpoint())
|
||||
mbox.pack_start(self._test_btn, False, False, 0)
|
||||
|
||||
bulk_lbl = Gtk.Label(label="Bulk add models (one per line or comma-separated):", xalign=0)
|
||||
area.pack_start(bulk_lbl, False, False, 2)
|
||||
@@ -1970,29 +2017,52 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
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()
|
||||
if (provider or "").startswith("google"):
|
||||
self._google_oauth_flow(provider)
|
||||
|
||||
def _google_oauth_flow(self):
|
||||
token_path = os.path.expanduser("~/.cache/codex-proxy/google-oauth-token.json")
|
||||
def _google_oauth_flow(self, oauth_provider="google-cli"):
|
||||
is_antigravity = oauth_provider == "google-antigravity"
|
||||
token_path = os.path.expanduser("~/.cache/codex-proxy/google-antigravity-oauth-token.json" if is_antigravity else "~/.cache/codex-proxy/google-cli-oauth-token.json")
|
||||
|
||||
CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
CLIENT_SECRET = "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w"
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/generative-language.retriever",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
import http.server, hashlib, secrets, socket
|
||||
if is_antigravity:
|
||||
CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
CLIENT_SECRET = "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"
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
]
|
||||
port = 51121
|
||||
redirect_uri = f"http://localhost:{port}/oauth-callback"
|
||||
callback_path = "/oauth-callback"
|
||||
provider_kind = "antigravity"
|
||||
else:
|
||||
CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
CLIENT_SECRET = "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"
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
port = 0
|
||||
redirect_uri = None
|
||||
callback_path = "/oauth2callback"
|
||||
provider_kind = "cli"
|
||||
|
||||
import http.server
|
||||
|
||||
port = 8085
|
||||
state = secrets.token_hex(32)
|
||||
verifier = secrets.token_urlsafe(32)
|
||||
challenge = hashlib.sha256(verifier.encode()).digest()
|
||||
challenge_b64 = urllib.parse.quote_plus(__import__('base64').urlsafe_b64encode(challenge).rstrip(b'=').decode())
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
|
||||
if port == 0:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
|
||||
|
||||
redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
|
||||
scope_str = " ".join(SCOPES)
|
||||
auth_url = (
|
||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
||||
@@ -2001,9 +2071,9 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
f"&response_type=code"
|
||||
f"&scope={urllib.parse.quote(scope_str)}"
|
||||
f"&access_type=offline"
|
||||
f"&prompt=consent"
|
||||
f"&prompt=select_account%20consent"
|
||||
f"&state={state}"
|
||||
f"&code_challenge={challenge_b64}"
|
||||
f"&code_challenge={challenge}"
|
||||
f"&code_challenge_method=S256"
|
||||
)
|
||||
|
||||
@@ -2043,6 +2113,14 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
qs = urllib.parse.urlparse(self2.path).query
|
||||
params = urllib.parse.parse_qs(qs)
|
||||
received_state[0] = params.get("state", [None])[0]
|
||||
with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
|
||||
_dbg.write(f"[{time.strftime('%H:%M:%S')}] GET {self2.path} state={received_state[0]} code={'code' in params}\n")
|
||||
if self2.path.find(callback_path) == -1:
|
||||
self2.send_response(302)
|
||||
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
|
||||
self2.end_headers()
|
||||
error_holder[0] = "unexpected request"
|
||||
return
|
||||
if "code" in params:
|
||||
if received_state[0] != state:
|
||||
self2.send_response(400)
|
||||
@@ -2061,63 +2139,171 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
self2.send_response(302)
|
||||
self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
|
||||
self2.end_headers()
|
||||
def log_message(self2, *a): pass
|
||||
def log_message(self2, fmt, *args):
|
||||
with open("/tmp/codex-oauth-debug.log", "a") as _dbg:
|
||||
_dbg.write(f"[{time.strftime('%H:%M:%S')}] {fmt % args}\n")
|
||||
|
||||
try:
|
||||
server = http.server.HTTPServer(("127.0.0.1", port), OAuthHandler)
|
||||
bind_host = "localhost" if is_antigravity else "127.0.0.1"
|
||||
server = http.server.HTTPServer((bind_host, port), OAuthHandler)
|
||||
except OSError:
|
||||
self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.")
|
||||
spinner.stop()
|
||||
dlg.run(); dlg.destroy()
|
||||
return
|
||||
|
||||
def _oauth_log(msg):
|
||||
with open("/tmp/codex-oauth-debug.log", "a") as _f:
|
||||
_f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
||||
|
||||
_oauth_log(f"Starting OAuth: port={port} redirect_uri={redirect_uri}")
|
||||
|
||||
def wait_for_code():
|
||||
server.handle_request()
|
||||
_oauth_log("wait_for_code thread started")
|
||||
deadline = time.time() + 120
|
||||
while code_holder[0] is None and error_holder[0] is None and time.time() < deadline:
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
GLib.idle_add(self._google_oauth_complete_gemini, dlg, code_holder, error_holder,
|
||||
CLIENT_ID, CLIENT_SECRET, redirect_uri, token_path, spinner, verifier)
|
||||
_oauth_log(f"Server closed. code={'yes' if code_holder[0] else 'no'} error={'yes' if error_holder[0] else 'no'}")
|
||||
if code_holder[0]:
|
||||
try:
|
||||
_oauth_log("Exchanging code for token...")
|
||||
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",
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
tokens = json.loads(resp.read())
|
||||
tokens["client_id"] = CLIENT_ID
|
||||
tokens["client_secret"] = CLIENT_SECRET
|
||||
tokens["provider_kind"] = provider_kind
|
||||
tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
|
||||
os.makedirs(os.path.dirname(token_path), exist_ok=True)
|
||||
with open(token_path, "w") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
_oauth_log(f"Token saved to {token_path}")
|
||||
project_id = ""
|
||||
try:
|
||||
_oauth_log("Discovering project ID via loadCodeAssist...")
|
||||
lr = urllib.request.Request(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
data=json.dumps({}).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {tokens['access_token']}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
})
|
||||
lresp = urllib.request.urlopen(lr, timeout=15)
|
||||
ldata = json.loads(lresp.read())
|
||||
p = ldata.get("cloudaicompanionProject", "")
|
||||
if isinstance(p, dict):
|
||||
project_id = p.get("id", "")
|
||||
elif isinstance(p, str):
|
||||
project_id = p
|
||||
_oauth_log(f"Project ID: {project_id or '(none)'}")
|
||||
if project_id:
|
||||
tokens["project_id"] = project_id
|
||||
with open(token_path, "w") as f2:
|
||||
json.dump(tokens, f2, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
except Exception as pe:
|
||||
_oauth_log(f"loadCodeAssist failed (non-fatal): {pe}")
|
||||
if is_antigravity:
|
||||
found_models = [
|
||||
"gemini-2.5-flash", "gemini-2.5-pro",
|
||||
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-low", "gemini-3-pro-high",
|
||||
"gemini-3.1-pro-low", "gemini-3.1-pro-high",
|
||||
"gemini-3-flash-low", "gemini-3-flash-medium", "gemini-3-flash-high",
|
||||
"claude-sonnet-4-6", "claude-opus-4-6-thinking",
|
||||
"claude-opus-4-6-thinking-low", "claude-opus-4-6-thinking-medium", "claude-opus-4-6-thinking-high",
|
||||
"gemini-claude-sonnet-4-6",
|
||||
"gemini-claude-opus-4-6-thinking-low", "gemini-claude-opus-4-6-thinking-medium", "gemini-claude-opus-4-6-thinking-high",
|
||||
"gemini-3-pro-image",
|
||||
]
|
||||
probe_candidates = [
|
||||
"gemini-2.5-flash", "gemini-2.5-pro",
|
||||
"gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
|
||||
]
|
||||
_oauth_log(f"Probing {len(probe_candidates)} model candidates...")
|
||||
for mc in probe_candidates:
|
||||
try:
|
||||
pr = urllib.request.Request(
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent",
|
||||
data=json.dumps({
|
||||
"project": project_id,
|
||||
"model": mc,
|
||||
"request": {"contents": [{"role": "user", "parts": [{"text": "x"}]}],
|
||||
"generationConfig": {"maxOutputTokens": 1}},
|
||||
}).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {tokens['access_token']}",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
|
||||
})
|
||||
pr.get_method = lambda: "POST"
|
||||
resp = urllib.request.urlopen(pr, timeout=10)
|
||||
resp.read()
|
||||
found_models.append(mc)
|
||||
_oauth_log(f" {mc} → available")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429:
|
||||
found_models.append(mc)
|
||||
_oauth_log(f" {mc} → available (rate limited)")
|
||||
else:
|
||||
e.read()
|
||||
_oauth_log(f" {mc} → HTTP {e.code}")
|
||||
except Exception as e:
|
||||
_oauth_log(f" {mc} → error: {e}")
|
||||
else:
|
||||
found_models = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
||||
if found_models:
|
||||
tokens["available_models"] = found_models
|
||||
with open(token_path, "w") as f3:
|
||||
json.dump(tokens, f3, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
_oauth_log(f"Discovered {len(found_models)} models: {found_models}")
|
||||
else:
|
||||
_oauth_log("No models discovered (will use defaults)")
|
||||
GLib.idle_add(self._oauth_success, dlg, tokens.get("access_token", ""), spinner)
|
||||
return
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors='replace')
|
||||
_oauth_log(f"Token exchange HTTP {e.code}: {body}")
|
||||
GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed ({e.code}): {body[:200]}", spinner)
|
||||
return
|
||||
except Exception as e:
|
||||
_oauth_log(f"Token exchange FAILED: {e}")
|
||||
GLib.idle_add(self._oauth_failed, dlg, f"Token exchange failed: {e}", spinner)
|
||||
return
|
||||
_oauth_log(f"OAuth failed: {error_holder[0] or 'timeout'}")
|
||||
GLib.idle_add(self._oauth_failed, dlg,
|
||||
error_holder[0] or "No authorization code received.", spinner)
|
||||
|
||||
threading.Thread(target=wait_for_code, daemon=True).start()
|
||||
subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
dlg.connect("response", lambda d, r: d.destroy())
|
||||
dlg.run()
|
||||
dlg.destroy()
|
||||
|
||||
def _google_oauth_complete_gemini(self, dlg, code_holder, error_holder,
|
||||
client_id, client_secret, redirect_uri, token_path, spinner, verifier):
|
||||
def _oauth_success(self, dlg, access_token, spinner):
|
||||
spinner.stop()
|
||||
if error_holder[0]:
|
||||
self._oauth_status.set_markup(f'<span foreground="#e74c3c">Error: {error_holder[0]}</span>')
|
||||
return
|
||||
if not code_holder[0]:
|
||||
self._oauth_status.set_text("No authorization code received.")
|
||||
return
|
||||
self._entry_key.set_text(access_token)
|
||||
self._oauth_status.set_markup('<span foreground="#27ae60" weight="bold">Authorization successful! Token saved.</span>')
|
||||
dlg.set_title("Google OAuth — Success")
|
||||
GLib.timeout_add(1500, lambda: dlg.response(Gtk.ResponseType.OK))
|
||||
|
||||
self._oauth_status.set_text("Exchanging code for token…")
|
||||
try:
|
||||
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",
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
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)
|
||||
os.makedirs(os.path.dirname(token_path), exist_ok=True)
|
||||
with open(token_path, "w") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
os.chmod(token_path, 0o600)
|
||||
self._entry_key.set_text(tokens.get("access_token", ""))
|
||||
self._oauth_status.set_markup('<span foreground="#27ae60" weight="bold">Authorization successful! Token saved.</span>')
|
||||
dlg.set_title("Google OAuth — Success")
|
||||
except Exception as e:
|
||||
self._oauth_status.set_markup(f'<span foreground="#e74c3c">Token exchange failed: {e}</span>')
|
||||
def _oauth_failed(self, dlg, msg, spinner):
|
||||
spinner.stop()
|
||||
self._oauth_status.set_markup(f'<span foreground="#e74c3c">{msg}</span>')
|
||||
GLib.timeout_add(3000, lambda: dlg.response(Gtk.ResponseType.CANCEL))
|
||||
|
||||
def _remove_model(self, path):
|
||||
current = self._combo_default.get_active_text()
|
||||
@@ -2163,6 +2349,70 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
return True, None
|
||||
return False, err or "No models returned by endpoint"
|
||||
|
||||
def _diagnose_endpoint(self):
|
||||
url = self._entry_url.get_text().strip()
|
||||
key = self._entry_key.get_text().strip()
|
||||
bt = self._combo_type.get_active_id() or "openai-compat"
|
||||
model = self._combo_default.get_active_text() or ""
|
||||
|
||||
checks = []
|
||||
def add(name, ok, detail=""):
|
||||
checks.append((name, ok, detail))
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
add("URL format", bool(parsed.scheme and parsed.netloc),
|
||||
url if parsed.scheme else "Missing scheme (https://)")
|
||||
|
||||
try:
|
||||
t0 = time.time()
|
||||
ep = {"base_url": url, "api_key": key, "backend_type": bt}
|
||||
ids, err = fetch_models_for_endpoint(ep)
|
||||
lat = (time.time() - t0) * 1000
|
||||
if ids:
|
||||
add("Network reachable", True, f"{lat:.0f}ms")
|
||||
add("Auth valid", True)
|
||||
add("/models endpoint", True, f"{len(ids)} models in {lat:.0f}ms")
|
||||
if model:
|
||||
add("Selected model exists", model in ids,
|
||||
model if model in ids else f"'{model}' not in {ids[:5]}...")
|
||||
else:
|
||||
add("Selected model", False, "No model selected")
|
||||
elif err and ("401" in str(err) or "403" in str(err)):
|
||||
add("Network reachable", True, f"{lat:.0f}ms")
|
||||
add("Auth valid", False, str(err)[:100])
|
||||
add("/models endpoint", False, "Auth failed")
|
||||
else:
|
||||
add("Network reachable", False, str(err or "no response")[:100])
|
||||
except Exception as e:
|
||||
add("Network", False, str(e)[:100])
|
||||
|
||||
dlg = Gtk.Dialog(title="Endpoint Doctor", parent=self, modal=True)
|
||||
dlg.add_button("Close", Gtk.ResponseType.CLOSE)
|
||||
dlg.set_default_size(420, 300)
|
||||
area = dlg.get_content_area()
|
||||
area.set_margin_start(12)
|
||||
area.set_margin_end(12)
|
||||
area.set_margin_top(12)
|
||||
area.set_margin_bottom(12)
|
||||
area.set_spacing(4)
|
||||
for name, ok, detail in checks:
|
||||
row = Gtk.Box(spacing=6)
|
||||
icon = Gtk.Label()
|
||||
icon.set_markup(f'<span foreground="{"#27ae60" if ok else "#e74c3c"}"'
|
||||
f' weight="bold">{"\u2713" if ok else "\u2717"}</span>')
|
||||
row.pack_start(icon, False, False, 0)
|
||||
lbl = Gtk.Label()
|
||||
lbl.set_markup(f'<b>{name}</b>')
|
||||
row.pack_start(lbl, False, False, 0)
|
||||
if detail:
|
||||
det = Gtk.Label()
|
||||
det.set_markup(f'<span foreground="#7f8c8d" size="small">{detail}</span>')
|
||||
row.pack_end(det, False, False, 0)
|
||||
area.pack_start(row, False, False, 0)
|
||||
dlg.show_all()
|
||||
dlg.run()
|
||||
dlg.destroy()
|
||||
|
||||
def _on_response(self, dialog, response):
|
||||
if response != Gtk.ResponseType.OK:
|
||||
self.destroy()
|
||||
@@ -2172,7 +2422,7 @@ class EditEndpointDialog(Gtk.Dialog):
|
||||
if not name:
|
||||
self._show_error("Name is required")
|
||||
return
|
||||
bt = self._combo_type.get_active_id()
|
||||
bt = self._combo_type.get_active_id() or PROVIDER_PRESETS.get(self._combo_preset.get_active_text() or "", {}).get("backend_type") or "openai-compat"
|
||||
url = self._entry_url.get_text().strip()
|
||||
key = self._entry_key.get_text().strip()
|
||||
models = [self._model_store[i][0] for i in range(len(self._model_store))]
|
||||
|
||||
Reference in New Issue
Block a user