diff --git a/CHANGELOG.md b/CHANGELOG.md
index 442aef4..26736a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## v2.5.0 (2026-05-20)
+
+- **AI BGP β Multi-provider routing with automatic failover**
+ - New "AI BGP" button in main window β pool manager
+ - Create BGP pools with ordered routes from any configured endpoint
+ - Each route has its own endpoint URL, API key, model, and priority
+ - **Failover strategy**: tries primary route, automatically falls back to next on error/timeout
+ - BGP pools appear in endpoint dropdown with π icon
+ - Pool editor: add/remove/reorder routes, pick endpoint + model per route
+ - Up/down buttons for priority reordering
+ - Proxy logs `[bgp] trying route 'Name'` and `[bgp] route 'Name' FAILED` on fallback
+ - If all routes fail: returns 502 with detailed error per route
+- Fixed TOML config breakage from multi-line paste in API key field (`_toml_safe()`)
+
## v2.4.0 (2026-05-20)
- **Added OpenAdapter provider preset**
diff --git a/codex-launcher_2.4.0_all.deb b/codex-launcher_2.4.0_all.deb
deleted file mode 100644
index e9e008e..0000000
Binary files a/codex-launcher_2.4.0_all.deb and /dev/null differ
diff --git a/codex-launcher_2.5.0_all.deb b/codex-launcher_2.5.0_all.deb
new file mode 100644
index 0000000..bd99b79
Binary files /dev/null and b/codex-launcher_2.5.0_all.deb differ
diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui
index 94da304..8cf1803 100755
--- a/src/codex-launcher-gui
+++ b/src/codex-launcher-gui
@@ -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="Codex Launcher v2.4.0")
+ lbl = Gtk.Label(label="Codex Launcher v2.5.0")
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="AI BGP Pools β 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="Routes (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)
diff --git a/src/translate-proxy.py b/src/translate-proxy.py
index b5cbe43..6d3997b 100755
--- a/src/translate-proxy.py
+++ b/src/translate-proxy.py
@@ -84,23 +84,35 @@ MODELS = CONFIG["models"]
CC_VERSION = CONFIG.get("cc_version", "")
REASONING_ENABLED = CONFIG.get("reasoning_enabled", True)
REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium")
+BGP_ROUTES = CONFIG.get("bgp_routes", [])
+BGP_MODELS = []
+for _r in BGP_ROUTES:
+ for _m in _r.get("models", [{"id": _r.get("model", "unknown")}]):
+ if _m.get("id", _m) not in BGP_MODELS:
+ BGP_MODELS.append(_m.get("id", _m) if isinstance(_m, dict) else _m)
+if BGP_ROUTES and not MODELS:
+ MODELS = [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in BGP_MODELS]
+ CONFIG["models"] = MODELS
def _refresh_oauth_token():
- if OAUTH_PROVIDER != "google":
- return API_KEY
+ return _refresh_oauth_token_for(API_KEY, OAUTH_PROVIDER)
+
+def _refresh_oauth_token_for(api_key, oauth_provider):
+ 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
+ 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)
+ 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)
+ 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,
@@ -1006,7 +1018,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
def _handle_openai_compat(self, body, model, stream):
input_data = body.get("input", "")
- # Adaptive: proactively compact if above learned Crof limit
crof_limit = _crof_item_limit(model)
if isinstance(input_data, list) and len(input_data) > crof_limit:
print(f"[crof-adaptive] proactive compact: {len(input_data)} items > limit {crof_limit}", file=sys.stderr)
@@ -1018,6 +1029,29 @@ class Handler(http.server.BaseHTTPRequestHandler):
instructions = body.get("instructions", "").strip()
if instructions:
messages.insert(0, {"role": "system", "content": instructions})
+
+ if BGP_ROUTES:
+ self._handle_bgp(body, model, stream, messages, input_data)
+ else:
+ chat_body = self._build_chat_body(model, messages, body, stream)
+ target = upstream_target(TARGET_URL, "/chat/completions")
+ effective_key = _refresh_oauth_token()
+ fwd = forwarded_headers(self.headers, {
+ "Content-Type": "application/json",
+ "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}", file=sys.stderr)
+ req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd)
+ try:
+ upstream = urllib.request.urlopen(req, timeout=180)
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()
+ return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}})
+ except Exception as e:
+ return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}})
+ self._forward_oa_compat(upstream, stream, model, chat_body, body, input_data, fwd, target)
+
+ def _build_chat_body(self, model, messages, body, stream):
chat_body = {"model": model, "messages": messages}
for k in ("temperature", "top_p"):
if k in body:
@@ -1034,31 +1068,63 @@ class Handler(http.server.BaseHTTPRequestHandler):
chat_body["reasoning_effort"] = "none"
else:
chat_body["reasoning_effort"] = REASONING_EFFORT
+ return chat_body
- target = upstream_target(TARGET_URL, "/chat/completions")
- effective_key = _refresh_oauth_token()
- fwd = forwarded_headers(self.headers, {
- "Content-Type": "application/json",
- "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)
+ def _handle_bgp(self, body, model, stream, messages, input_data):
+ routes = sorted(BGP_ROUTES, key=lambda r: r.get("priority", 99))
+ errors = []
+ for route in routes:
+ r_model = route.get("model", model)
+ r_url = route["target_url"].rstrip("/")
+ r_key = route.get("api_key", "")
+ r_reasoning = route.get("reasoning_enabled", True)
+ r_effort = route.get("reasoning_effort", "medium")
+ r_oauth = route.get("oauth_provider", "")
- req = urllib.request.Request(
- target,
- data=json.dumps(chat_body).encode(),
- headers=fwd,
- )
- self._forward_oa_compat(req, stream, model, chat_body, body, input_data, fwd, target, tools)
+ chat_body = dict(messages=list(messages))
+ chat_body["model"] = r_model
+ for k in ("temperature", "top_p"):
+ if k in body:
+ chat_body[k] = body[k]
+ chat_body["max_tokens"] = max(body.get("max_output_tokens", 0), 64000)
+ tools = oa_convert_tools(body.get("tools"))
+ if tools:
+ chat_body["tools"] = tools
+ if body.get("tool_choice"):
+ chat_body["tool_choice"] = body["tool_choice"]
+ chat_body["stream"] = stream
+ if not r_reasoning or r_effort == "none":
+ chat_body["enable_thinking"] = False
+ chat_body["reasoning_effort"] = "none"
+ else:
+ chat_body["reasoning_effort"] = r_effort
- def _forward_oa_compat(self, req, stream, model, chat_body, body, input_data, fwd, target, tools):
- try:
- upstream = urllib.request.urlopen(req, timeout=180)
- except urllib.error.HTTPError as e:
- err = e.read().decode()
- return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}})
- except Exception as e:
- return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}})
+ target = upstream_target(r_url, "/chat/completions")
+ if r_oauth == "google":
+ r_key = _refresh_oauth_token_for(r_key, r_oauth)
+ fwd = forwarded_headers(self.headers, {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {r_key}",
+ }, browser_ua=True)
+ print(f"[bgp] trying route '{route.get('name', r_url)}' model={r_model}", file=sys.stderr)
+ req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd)
+ try:
+ upstream = urllib.request.urlopen(req, timeout=180)
+ print(f"[bgp] route '{route.get('name', r_url)}' connected OK", file=sys.stderr)
+ self._forward_oa_compat(upstream, stream, r_model, chat_body, body, input_data, fwd, target)
+ return
+ except urllib.error.HTTPError as e:
+ err = e.read().decode()
+ print(f"[bgp] route '{route.get('name', r_url)}' FAILED: HTTP {e.code}: {err[:200]}", file=sys.stderr)
+ errors.append(f"{route.get('name','?')}: HTTP {e.code}")
+ except Exception as e:
+ print(f"[bgp] route '{route.get('name', r_url)}' FAILED: {e}", file=sys.stderr)
+ errors.append(f"{route.get('name','?')}: {e}")
+ print(f"[bgp] ALL ROUTES FAILED: {errors}", file=sys.stderr)
+ self.send_json(502, {"error": {"type": "bgp_all_routes_failed", "message": f"All BGP routes failed: {'; '.join(errors)}"}})
+
+ def _forward_oa_compat(self, upstream, stream, model, chat_body, body, input_data, fwd, target):
n_items = len(input_data) if isinstance(input_data, list) else 1
if stream: