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

@@ -1,5 +1,19 @@
# Changelog # 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) ## v2.4.0 (2026-05-20)
- **Added OpenAdapter provider preset** - **Added OpenAdapter provider preset**

Binary file not shown.

Binary file not shown.

View File

@@ -15,6 +15,7 @@ CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak"
CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh" CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh"
PROXY = HOME / ".local/bin/translate-proxy.py" PROXY = HOME / ".local/bin/translate-proxy.py"
ENDPOINTS_FILE = HOME / ".codex/endpoints.json" ENDPOINTS_FILE = HOME / ".codex/endpoints.json"
BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
LOG_DIR = HOME / ".cache/codex-desktop" LOG_DIR = HOME / ".cache/codex-desktop"
LAUNCH_LOG = LOG_DIR / "launcher.log" LAUNCH_LOG = LOG_DIR / "launcher.log"
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy" PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
@@ -24,6 +25,15 @@ model_catalog_json = ""
""" """
CHANGELOG = [ 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", [ ("2.4.0", "2026-05-20", [
"Added OpenAdapter provider preset (api.openadapter.in)", "Added OpenAdapter provider preset (api.openadapter.in)",
"One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini", "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.parent.mkdir(parents=True, exist_ok=True)
ENDPOINTS_FILE.write_text(json.dumps(data, indent=2)) 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): def get_endpoint(name):
for e in load_endpoints()["endpoints"]: for e in load_endpoints()["endpoints"]:
if e["name"] == name: 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 = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}.json"
pcfg_path.parent.mkdir(parents=True, exist_ok=True) pcfg_path.parent.mkdir(parents=True, exist_ok=True)
pcfg_path.write_text(json.dumps(pcfg, indent=2)) 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( _proxy_proc = subprocess.Popen(
["python3", str(PROXY), "--config", str(pcfg_path)], ["python3", str(PROXY), "--config", str(pcfg_path)],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
preexec_fn=os.setsid, preexec_fn=os.setsid,
) )
for _ in range(30): for _ in range(30):
try: try:
urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2) urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2)
@@ -605,12 +629,15 @@ 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.4.0</b>") lbl = Gtk.Label(label="<b>Codex Launcher v2.5.0</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")
changelog_btn.connect("clicked", lambda b: self._show_changelog()) changelog_btn.connect("clicked", lambda b: self._show_changelog())
hdr.pack_end(changelog_btn, False, False, 0) 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 = Gtk.Button(label="Manage Endpoints")
mgr_btn.connect("clicked", lambda b: self._open_mgr()) mgr_btn.connect("clicked", lambda b: self._open_mgr())
hdr.pack_end(mgr_btn, False, False, 0) 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"]] names = [e["name"] for e in self._endpoints_data["endpoints"]]
for n in names: for n in names:
self._combo.append_text(n) 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") default = self._endpoints_data.get("default")
if default and default in names: if default and default in names:
self._combo.set_active(names.index(default)) self._combo.set_active(names.index(default))
@@ -854,9 +884,26 @@ class LauncherWin(Gtk.Window):
def _on_endpoint_changed(self): def _on_endpoint_changed(self):
name = self._combo.get_active_text() 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() 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", []): for m in ep.get("models", []):
self._model_combo.append_text(m) self._model_combo.append_text(m)
GLib.idle_add(self._select_default_model, ep) 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 = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
d.run(); d.destroy() 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): def _backup_profile(self):
chooser = Gtk.FileChooserDialog( chooser = Gtk.FileChooserDialog(
title="Backup Codex Profile", title="Backup Codex Profile",
@@ -1067,8 +1123,7 @@ class LauncherWin(Gtk.Window):
def _launch(self, target): def _launch(self, target):
name = self._combo.get_active_text() name = self._combo.get_active_text()
ep = get_endpoint(name) if name else None if not name:
if not ep:
self.log("ERROR: no endpoint selected") self.log("ERROR: no endpoint selected")
return return
model = self._model_combo.get_active_text() model = self._model_combo.get_active_text()
@@ -1076,6 +1131,26 @@ class LauncherWin(Gtk.Window):
self.log("ERROR: no model selected") self.log("ERROR: no model selected")
return 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._set_busy(True)
self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===") self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===")
threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start() 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._set_busy(False)
self.log("Ready.") 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): def _run_codex_default(self, target):
try: try:
self.log("Cleaning up stale processes…") self.log("Cleaning up stale processes…")
@@ -1967,6 +2084,381 @@ class EditEndpointDialog(Gtk.Dialog):
# Entry point # 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(): def main():
for d in [LOG_DIR, PROXY_CONFIG_DIR]: for d in [LOG_DIR, PROXY_CONFIG_DIR]:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)

View File

@@ -84,23 +84,35 @@ 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")
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(): def _refresh_oauth_token():
if OAUTH_PROVIDER != "google": return _refresh_oauth_token_for(API_KEY, OAUTH_PROVIDER)
return API_KEY
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") token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "google-oauth-token.json")
if not os.path.exists(token_path): if not os.path.exists(token_path):
return API_KEY return api_key
try: try:
with open(token_path) as f: with open(token_path) as f:
tokens = json.load(f) tokens = json.load(f)
if tokens.get("expires_at", 0) > time.time() + 60: 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_id = tokens.get("client_id", "")
client_secret = tokens.get("client_secret", "") client_secret = tokens.get("client_secret", "")
refresh_token = tokens.get("refresh_token", "") refresh_token = tokens.get("refresh_token", "")
if not all([client_id, client_secret, 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) print("[oauth] refreshing Google access token...", file=sys.stderr)
data = urllib.parse.urlencode({ data = urllib.parse.urlencode({
"client_id": client_id, "client_secret": client_secret, "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): def _handle_openai_compat(self, body, model, stream):
input_data = body.get("input", "") input_data = body.get("input", "")
# Adaptive: proactively compact if above learned Crof limit
crof_limit = _crof_item_limit(model) crof_limit = _crof_item_limit(model)
if isinstance(input_data, list) and len(input_data) > crof_limit: 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) 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() instructions = body.get("instructions", "").strip()
if instructions: if instructions:
messages.insert(0, {"role": "system", "content": 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} chat_body = {"model": model, "messages": messages}
for k in ("temperature", "top_p"): for k in ("temperature", "top_p"):
if k in body: if k in body:
@@ -1034,31 +1068,63 @@ class Handler(http.server.BaseHTTPRequestHandler):
chat_body["reasoning_effort"] = "none" chat_body["reasoning_effort"] = "none"
else: else:
chat_body["reasoning_effort"] = REASONING_EFFORT chat_body["reasoning_effort"] = REASONING_EFFORT
return chat_body
target = upstream_target(TARGET_URL, "/chat/completions") def _handle_bgp(self, body, model, stream, messages, input_data):
effective_key = _refresh_oauth_token() routes = sorted(BGP_ROUTES, key=lambda r: r.get("priority", 99))
fwd = forwarded_headers(self.headers, { errors = []
"Content-Type": "application/json", for route in routes:
"Authorization": f"Bearer {effective_key}", r_model = route.get("model", model)
}, browser_ua=True) r_url = route["target_url"].rstrip("/")
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) 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( chat_body = dict(messages=list(messages))
target, chat_body["model"] = r_model
data=json.dumps(chat_body).encode(), for k in ("temperature", "top_p"):
headers=fwd, if k in body:
) chat_body[k] = body[k]
self._forward_oa_compat(req, stream, model, chat_body, body, input_data, fwd, target, tools) 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): target = upstream_target(r_url, "/chat/completions")
try: if r_oauth == "google":
upstream = urllib.request.urlopen(req, timeout=180) r_key = _refresh_oauth_token_for(r_key, r_oauth)
except urllib.error.HTTPError as e: fwd = forwarded_headers(self.headers, {
err = e.read().decode() "Content-Type": "application/json",
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) "Authorization": f"Bearer {r_key}",
except Exception as e: }, browser_ua=True)
return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) 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 n_items = len(input_data) if isinstance(input_data, list) else 1
if stream: if stream: