v2.5.0: AI BGP multi-provider routing with automatic failover

- New AI BGP pool manager (create/edit/delete pools)
- Each pool has ordered routes from any configured endpoint
- Failover: tries primary, falls back to next route on error
- Pools appear in endpoint dropdown with shuffle icon
- Pool editor with route add/remove/reorder
- Fixed TOML breakage from multi-line paste
- Added OpenAdapter preset with 0G models
This commit is contained in:
Roman
2026-05-20 16:40:57 +04:00
Unverified
parent 0f333aab6e
commit 12ca136fba
5 changed files with 606 additions and 34 deletions

View File

@@ -15,6 +15,7 @@ CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak"
CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh"
PROXY = HOME / ".local/bin/translate-proxy.py"
ENDPOINTS_FILE = HOME / ".codex/endpoints.json"
BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
LOG_DIR = HOME / ".cache/codex-desktop"
LAUNCH_LOG = LOG_DIR / "launcher.log"
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
@@ -24,6 +25,15 @@ model_catalog_json = ""
"""
CHANGELOG = [
("2.5.0", "2026-05-20", [
"AI BGP — multi-provider routing with automatic failover",
"Create BGP pools with ordered routes from any configured endpoint",
"Each route uses its own endpoint URL, API key, and model",
"Failover strategy: tries primary, falls back on error/timeout",
"BGP pools appear in endpoint dropdown with shuffle icon",
"Up/down reordering for route priority in pool editor",
"Fixed TOML config breakage from multi-line paste in fields",
]),
("2.4.0", "2026-05-20", [
"Added OpenAdapter provider preset (api.openadapter.in)",
"One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini",
@@ -345,6 +355,18 @@ def save_endpoints(data):
ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))
def load_bgp_pools():
if BGP_POOLS_FILE.exists():
try:
return json.loads(BGP_POOLS_FILE.read_text())
except Exception:
pass
return {"pools": []}
def save_bgp_pools(data):
BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))
def get_endpoint(name):
for e in load_endpoints()["endpoints"]:
if e["name"] == name:
@@ -513,13 +535,15 @@ def _start_proxy_for(endpoint, logfn):
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}.json"
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
pcfg_path.write_text(json.dumps(pcfg, indent=2))
_start_proxy_with_config(pcfg_path, logfn)
def _start_proxy_with_config(pcfg_path, logfn):
global _proxy_proc
_proxy_proc = subprocess.Popen(
["python3", str(PROXY), "--config", str(pcfg_path)],
stdout=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
for _ in range(30):
try:
urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2)
@@ -605,12 +629,15 @@ 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.4.0</b>")
lbl = Gtk.Label(label="<b>Codex Launcher v2.5.0</b>")
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)
bgp_btn = Gtk.Button(label="AI BGP")
bgp_btn.connect("clicked", lambda b: self._open_bgp())
hdr.pack_end(bgp_btn, False, False, 0)
mgr_btn = Gtk.Button(label="Manage Endpoints")
mgr_btn.connect("clicked", lambda b: self._open_mgr())
hdr.pack_end(mgr_btn, False, False, 0)
@@ -844,7 +871,10 @@ class LauncherWin(Gtk.Window):
names = [e["name"] for e in self._endpoints_data["endpoints"]]
for n in names:
self._combo.append_text(n)
if names:
bgp_names = [p["name"] for p in load_bgp_pools().get("pools", [])]
for n in bgp_names:
self._combo.append_text(f"🔀 {n}")
if names or bgp_names:
default = self._endpoints_data.get("default")
if default and default in names:
self._combo.set_active(names.index(default))
@@ -854,9 +884,26 @@ class LauncherWin(Gtk.Window):
def _on_endpoint_changed(self):
name = self._combo.get_active_text()
ep = get_endpoint(name) if name else None
is_bgp = name and name.startswith("🔀 ")
bgp_name = name[2:] if is_bgp else None
ep = get_endpoint(name) if name and not is_bgp else None
self._model_combo.remove_all()
if ep:
if is_bgp:
pool = None
for p in load_bgp_pools().get("pools", []):
if p["name"] == bgp_name:
pool = p
break
if pool:
seen = set()
for r in pool.get("routes", []):
m = r.get("model", "")
if m and m not in seen:
self._model_combo.append_text(m)
seen.add(m)
if seen:
self._model_combo.set_active(0)
elif ep:
for m in ep.get("models", []):
self._model_combo.append_text(m)
GLib.idle_add(self._select_default_model, ep)
@@ -880,6 +927,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_bgp(self):
try:
self._bgp_window = BGPPoolMgr(self)
self._bgp_window.connect("destroy", lambda *_: setattr(self, "_bgp_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",
@@ -1067,8 +1123,7 @@ class LauncherWin(Gtk.Window):
def _launch(self, target):
name = self._combo.get_active_text()
ep = get_endpoint(name) if name else None
if not ep:
if not name:
self.log("ERROR: no endpoint selected")
return
model = self._model_combo.get_active_text()
@@ -1076,6 +1131,26 @@ class LauncherWin(Gtk.Window):
self.log("ERROR: no model selected")
return
is_bgp = name.startswith("🔀 ")
if is_bgp:
pool_name = name[2:]
pool = None
for p in load_bgp_pools().get("pools", []):
if p["name"] == pool_name:
pool = p
break
if not pool:
self.log(f"ERROR: BGP pool '{pool_name}' not found")
return
self._set_busy(True)
self.log(f"=== 🔀 BGP: {pool_name} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===")
threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start()
return
ep = get_endpoint(name)
if not ep:
self.log("ERROR: endpoint not found")
return
self._set_busy(True)
self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===")
threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start()
@@ -1128,6 +1203,48 @@ class LauncherWin(Gtk.Window):
self._set_busy(False)
self.log("Ready.")
def _run_bgp(self, pool, model, target):
try:
self.log("Cleaning up stale processes…")
_run_cleanup()
self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes…")
bgp_ep = {
"name": pool["name"],
"backend_type": "openai-compat",
"base_url": "http://bgp.placeholder",
"api_key": "",
"default_model": model,
"models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
}
pcfg = {
"port": 8080,
"backend_type": "openai-compat",
"target_url": "http://bgp.placeholder",
"api_key": "",
"bgp_routes": pool.get("routes", []),
"models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
}
pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}.json"
pcfg_path.parent.mkdir(parents=True, exist_ok=True)
pcfg_path.write_text(json.dumps(pcfg, indent=2))
_start_proxy_with_config(pcfg_path, self.log)
write_config_for_translated(bgp_ep, model)
if target == "desktop":
self._launch_desktop(bgp_ep, model)
else:
self._launch_cli(bgp_ep, model)
except Exception as e:
self.log(f"ERROR: {e}")
finally:
_stop_proxy()
restore_config()
self._set_busy(False)
self.log("Ready.")
def _run_codex_default(self, target):
try:
self.log("Cleaning up stale processes…")
@@ -1967,6 +2084,381 @@ class EditEndpointDialog(Gtk.Dialog):
# Entry point
# ═══════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════
# BGP Pool Manager
# ═══════════════════════════════════════════════════════════════════
class BGPPoolMgr(Gtk.Window):
def __init__(self, parent):
super().__init__(title="AI BGP — Pool Manager")
self.set_transient_for(parent)
self.set_default_size(620, 440)
self._parent = parent
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
vbox.set_margin_start(12)
vbox.set_margin_end(12)
vbox.set_margin_top(12)
vbox.set_margin_bottom(12)
self.add(vbox)
hdr = Gtk.Box(spacing=8)
vbox.pack_start(hdr, False, False, 0)
hdr.pack_start(Gtk.Label(label="<b>AI BGP Pools</b> — multi-provider routing with automatic failover", use_markup=True), False, False, 0)
self._store = Gtk.ListStore(str, str, str)
self._tree = Gtk.TreeView(model=self._store)
for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]):
r = Gtk.CellRendererText()
c = Gtk.TreeViewColumn(title, r, text=i)
c.set_min_width(w)
self._tree.append_column(c)
self._tree.set_headers_visible(True)
sw = Gtk.ScrolledWindow()
sw.add(self._tree)
vbox.pack_start(sw, True, True, 0)
sel = self._tree.get_selection()
sel.connect("changed", lambda *_: self._on_select())
bbox = Gtk.Box(spacing=8)
vbox.pack_start(bbox, False, False, 0)
self._add_btn = Gtk.Button(label="Create Pool")
self._add_btn.connect("clicked", lambda b: self._add_pool())
bbox.pack_start(self._add_btn, True, True, 0)
self._edit_btn = Gtk.Button(label="Edit Pool")
self._edit_btn.connect("clicked", lambda b: self._edit_pool())
self._edit_btn.set_sensitive(False)
bbox.pack_start(self._edit_btn, True, True, 0)
self._del_btn = Gtk.Button(label="Delete Pool")
self._del_btn.connect("clicked", lambda b: self._del_pool())
self._del_btn.set_sensitive(False)
bbox.pack_start(self._del_btn, True, True, 0)
close_btn = Gtk.Button(label="Close")
close_btn.connect("clicked", lambda b: self.destroy())
bbox.pack_start(close_btn, True, True, 0)
self._rebuild()
self.show_all()
def _rebuild(self):
self._store.clear()
for pool in load_bgp_pools().get("pools", []):
routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")])
def _selected_name(self):
sel = self._tree.get_selection()
m, i = sel.get_selected()
return self._store[i][0] if i else None
def _on_select(self):
name = self._selected_name()
self._edit_btn.set_sensitive(bool(name))
self._del_btn.set_sensitive(bool(name))
def _add_pool(self):
d = BGPPoolEditDialog(self, None)
d.connect("response", lambda *_: self._rebuild())
def _edit_pool(self):
name = self._selected_name()
if name:
d = BGPPoolEditDialog(self, name)
d.connect("response", lambda *_: self._rebuild())
def _del_pool(self):
name = self._selected_name()
if not name:
return
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
f'Delete BGP pool "{name}"?')
r = d.run(); d.destroy()
if r != Gtk.ResponseType.YES:
return
data = load_bgp_pools()
data["pools"] = [p for p in data["pools"] if p["name"] != name]
save_bgp_pools(data)
self._rebuild()
self._parent._on_endpoints_updated()
class BGPPoolEditDialog(Gtk.Dialog):
def __init__(self, parent, existing_name):
title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True)
self.add_button("Cancel", Gtk.ResponseType.CANCEL)
self.add_button("Save", Gtk.ResponseType.OK)
self.set_default_size(580, 480)
self._existing_name = existing_name
self._parent_mgr = parent
data = load_bgp_pools()
pool = None
if existing_name:
for p in data.get("pools", []):
if p["name"] == existing_name:
pool = p
break
if not pool:
pool = {"name": "", "strategy": "failover", "routes": []}
area = self.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(8)
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
area.pack_start(grid, False, False, 0)
grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1)
self._entry_name = Gtk.Entry(text=pool["name"])
grid.attach(self._entry_name, 1, 0, 1, 1)
grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1)
self._combo_strategy = Gtk.ComboBoxText()
self._combo_strategy.append("failover", "Failover (try primary, fall back on error)")
self._combo_strategy.append("race", "Race (send to all, return fastest)")
self._combo_strategy.set_active_id(pool.get("strategy", "failover"))
grid.attach(self._combo_strategy, 1, 1, 1, 1)
area.pack_start(Gtk.Label(label="<b>Routes</b> (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)
self._route_store = Gtk.ListStore(str, str, str, str, str, str)
for r in pool.get("routes", []):
self._route_store.append([
r.get("name", ""), r.get("endpoint_name", ""),
r.get("target_url", ""), r.get("api_key", ""),
r.get("model", ""), str(r.get("priority", 99))
])
self._route_tree = Gtk.TreeView(model=self._route_store)
for i, (title, w) in enumerate([
("Route Name", 120), ("Endpoint", 120), ("URL", 150),
("API Key", 80), ("Model", 120), ("Priority", 60)
]):
renderer = Gtk.CellRendererText()
renderer.set_property("editable", False)
col = Gtk.TreeViewColumn(title, renderer, text=i)
col.set_min_width(w)
col.set_resizable(True)
self._route_tree.append_column(col)
self._route_tree.set_headers_visible(True)
sw = Gtk.ScrolledWindow()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sw.add(self._route_tree)
sw.set_min_content_height(200)
area.pack_start(sw, True, True, 0)
bbox = Gtk.Box(spacing=6)
area.pack_start(bbox, False, False, 0)
add_r = Gtk.Button(label="Add Route")
add_r.connect("clicked", lambda b: self._add_route())
bbox.pack_start(add_r, True, True, 0)
edit_r = Gtk.Button(label="Edit Route")
edit_r.connect("clicked", lambda b: self._edit_route())
bbox.pack_start(edit_r, True, True, 0)
rm_r = Gtk.Button(label="Remove Route")
rm_r.connect("clicked", lambda b: self._remove_route())
bbox.pack_start(rm_r, True, True, 0)
up_r = Gtk.Button(label="↑ Up")
up_r.connect("clicked", lambda b: self._move_route(-1))
bbox.pack_start(up_r, True, True, 0)
down_r = Gtk.Button(label="↓ Down")
down_r.connect("clicked", lambda b: self._move_route(1))
bbox.pack_start(down_r, True, True, 0)
self.show_all()
if self.run() == Gtk.ResponseType.OK:
self._save()
self.destroy()
def _save(self):
name = self._entry_name.get_text().strip()
if not name:
return
strategy = self._combo_strategy.get_active_id() or "failover"
routes = []
for i, row in enumerate(self._route_store):
if not row[2]:
continue
routes.append({
"name": row[0] or f"Route {i+1}",
"endpoint_name": row[1],
"target_url": row[2],
"api_key": row[3],
"model": row[4],
"priority": i + 1,
"reasoning_enabled": True,
"reasoning_effort": "medium",
})
data = load_bgp_pools()
if self._existing_name:
data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
save_bgp_pools(data)
self._parent_mgr._parent._on_endpoints_updated()
def _add_route(self):
endpoints = load_endpoints().get("endpoints", [])
if not endpoints:
d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
"No endpoints configured. Add endpoints in Manage Endpoints first.")
d.run(); d.destroy()
return
d = BGPRouteDialog(self, endpoints, None)
if d.result:
r = d.result
self._route_store.append([
r.get("name", ""), r.get("endpoint_name", ""),
r.get("target_url", ""), r.get("api_key", ""),
r.get("model", ""), str(r.get("priority", 99))
])
def _edit_route(self):
sel = self._route_tree.get_selection()
m, i = sel.get_selected()
if not i:
return
endpoints = load_endpoints().get("endpoints", [])
existing = {
"name": m[i][0], "endpoint_name": m[i][1],
"target_url": m[i][2], "api_key": m[i][3],
"model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99,
}
d = BGPRouteDialog(self, endpoints, existing)
if d.result:
r = d.result
m[i][0] = r.get("name", "")
m[i][1] = r.get("endpoint_name", "")
m[i][2] = r.get("target_url", "")
m[i][3] = r.get("api_key", "")
m[i][4] = r.get("model", "")
m[i][5] = str(r.get("priority", 99))
def _remove_route(self):
sel = self._route_tree.get_selection()
m, i = sel.get_selected()
if i:
self._route_store.remove(i)
def _move_route(self, direction):
sel = self._route_tree.get_selection()
m, i = sel.get_selected()
if not i:
return
path = m.get_path(i)
idx = path.get_indices()[0]
new_idx = idx + direction
if new_idx < 0 or new_idx >= len(self._route_store):
return
row_data = [m[idx][c] for c in range(6)]
self._route_store.remove(m.get_iter(Gtk.TreePath(idx)))
new_iter = self._route_store.insert(new_idx)
for c, v in enumerate(row_data):
self._route_store.set_value(new_iter, c, v)
class BGPRouteDialog(Gtk.Dialog):
def __init__(self, parent, endpoints, existing):
Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True)
self.add_button("Cancel", Gtk.ResponseType.CANCEL)
self.add_button("OK", Gtk.ResponseType.OK)
self.set_default_size(440, 300)
self.result = None
area = self.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(6)
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
area.pack_start(grid, False, False, 0)
def add_row(row, label, widget):
grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1)
grid.attach(widget, 1, row, 1, 1)
self._entry_name = Gtk.Entry(text=existing.get("name", "") if existing else "")
add_row(0, "Route Name:", self._entry_name)
self._combo_ep = Gtk.ComboBoxText()
ep_names = [e["name"] for e in endpoints]
for en in ep_names:
self._combo_ep.append(en, en)
if existing and existing.get("endpoint_name") in ep_names:
self._combo_ep.set_active_id(existing["endpoint_name"])
elif ep_names:
self._combo_ep.set_active(0)
self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints))
add_row(1, "Endpoint:", self._combo_ep)
self._entry_url = Gtk.Entry()
add_row(2, "URL:", self._entry_url)
self._entry_key = Gtk.Entry()
self._entry_key.set_visibility(False)
add_row(3, "API Key:", self._entry_key)
self._combo_model = Gtk.ComboBoxText()
add_row(4, "Model:", self._combo_model)
if existing:
self._entry_url.set_text(existing.get("target_url", ""))
self._entry_key.set_text(existing.get("api_key", ""))
self._on_ep_changed(endpoints)
if existing and existing.get("model"):
self._combo_model.set_active_id(existing["model"])
self.show_all()
if self.run() == Gtk.ResponseType.OK:
ep_name = self._combo_ep.get_active_text() or ""
ep = None
for e in endpoints:
if e["name"] == ep_name:
ep = e
break
self.result = {
"name": self._entry_name.get_text().strip() or ep_name,
"endpoint_name": ep_name,
"target_url": self._entry_url.get_text().strip(),
"api_key": self._entry_key.get_text().strip(),
"model": self._combo_model.get_active_text() or "",
"priority": 99,
}
if ep:
self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
self.result["oauth_provider"] = ep.get("oauth_provider", "")
self.destroy()
def _on_ep_changed(self, endpoints):
ep_name = self._combo_ep.get_active_text()
ep = None
for e in endpoints:
if e["name"] == ep_name:
ep = e
break
if ep:
self._entry_url.set_text(normalize_base_url(ep.get("base_url", "")))
self._entry_key.set_text(ep.get("api_key", ""))
self._combo_model.remove_all()
for m in ep.get("models", []):
mid = normalize_model_id(m) if m else ""
self._combo_model.append(mid, m)
if ep.get("default_model"):
self._combo_model.set_active_id(normalize_model_id(ep["default_model"]))
elif len(ep.get("models", [])) > 0:
self._combo_model.set_active(0)
def main():
for d in [LOG_DIR, PROXY_CONFIG_DIR]:
d.mkdir(parents=True, exist_ok=True)