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: