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:
14
CHANGELOG.md
14
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**
|
||||
|
||||
Binary file not shown.
BIN
codex-launcher_2.5.0_all.deb
Normal file
BIN
codex-launcher_2.5.0_all.deb
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
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", "")
|
||||
|
||||
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
|
||||
|
||||
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 {effective_key}",
|
||||
"Authorization": f"Bearer {r_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)
|
||||
|
||||
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)
|
||||
|
||||
def _forward_oa_compat(self, req, stream, model, chat_body, body, input_data, fwd, target, tools):
|
||||
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()
|
||||
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}})
|
||||
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:
|
||||
return self.send_json(500, {"error": {"type": "proxy_error", "message": str(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:
|
||||
|
||||
Reference in New Issue
Block a user