mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[eric] 9router: port the 0.4.x /api auth gate (x-9r-cli-token) to unblock a version bump
This commit is contained in:
@@ -24,6 +24,8 @@ from .process import (
|
||||
NINE_ROUTER_PORT,
|
||||
NINE_ROUTER_URL,
|
||||
NINE_ROUTER_V1,
|
||||
cli_auth_headers,
|
||||
cli_auth_token,
|
||||
ensure_running,
|
||||
get_latest_reasoning_tokens,
|
||||
get_providers,
|
||||
@@ -67,6 +69,8 @@ __all__ = [
|
||||
"NINE_ROUTER_OPENAI_KEYED_PREFIX",
|
||||
"NINE_ROUTER_OPENROUTER_KEYED_NAME",
|
||||
"NINE_ROUTER_CUSTOM_NAME_SUFFIX",
|
||||
"cli_auth_headers",
|
||||
"cli_auth_token",
|
||||
"ensure_running",
|
||||
"stop",
|
||||
"is_running",
|
||||
|
||||
@@ -10,7 +10,7 @@ import os
|
||||
|
||||
import httpx
|
||||
|
||||
from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1
|
||||
from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1, cli_auth_headers
|
||||
from backend.apps.oauth_state import _pending_oauth, _mark_oauth_completed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -263,7 +263,7 @@ async def start_oauth(provider: str) -> dict:
|
||||
For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code}
|
||||
For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state}
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client:
|
||||
try:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code")
|
||||
if r.status_code == 200:
|
||||
@@ -310,7 +310,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None
|
||||
if extra_data:
|
||||
body["extraData"] = extra_data
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/poll",
|
||||
json=body,
|
||||
@@ -321,7 +321,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None
|
||||
|
||||
async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict:
|
||||
"""Exchange OAuth code for tokens via 9Router."""
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.post(
|
||||
f"{NINE_ROUTER_API}/oauth/{provider}/exchange",
|
||||
json={
|
||||
|
||||
@@ -11,8 +11,10 @@ API at localhost:20128/v1.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
@@ -27,34 +29,30 @@ NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}"
|
||||
NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api"
|
||||
NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
# Pinned 9router npm package version. Stays at 0.3.60.
|
||||
# Pinned 9router npm package version. Prod default stays 0.3.60; set
|
||||
# OPENSWARM_ROUTER_VERSION to stage a bump in dev (keys the dev cache by
|
||||
# version, so the override pulls a clean install) without shipping it.
|
||||
#
|
||||
# DO NOT bump to 0.4.x without porting 9Router API auth first. Tested 0.4.66
|
||||
# empirically (2026-06-01): it adds an auth gate to its internal /api/* routes,
|
||||
# so the endpoints our connect/sync flow calls without a token now 401 instead
|
||||
# of working:
|
||||
# endpoint 0.3.60 0.4.66
|
||||
# /api/oauth/<prov>/device-code 400 401 Unauthorized
|
||||
# POST /api/providers 400 401 Unauthorized
|
||||
# That 401 makes start_oauth() throw, which 500s EVERY subscription connect
|
||||
# (Claude/Codex/Gemini). oauth.py + sync.py would each need to discover and
|
||||
# send 9Router 0.4.x's API token on every /api/* call before a bump is viable.
|
||||
# 0.4.x gates its internal /api/* routes behind auth (the old bump blocker):
|
||||
# bare `POST /api/providers` / `/api/oauth/<prov>/device-code` now 401 instead
|
||||
# of working. That auth is now PORTED here: see cli_auth_token() / cli_auth_headers()
|
||||
# below, which compute the `x-9r-cli-token` 9Router checks and which every
|
||||
# /api/* call in this package attaches. The header is empty on 0.3.60 (no
|
||||
# machine-id file), so the old auth-free path is untouched.
|
||||
#
|
||||
# What the bump WOULD buy once auth is ported: cc/claude-opus-4-8 and cx/gpt-5.5
|
||||
# on the sub routes (gpt-5.5 404s on 0.3.60), and a reworked WebSearch behind a
|
||||
# new /api/v1/search route. Gemini 3.5 Flash is Antigravity-only there
|
||||
# (ag/gemini-3.5-flash-low), never on the gc/ Gemini-CLI lane.
|
||||
# What the bump buys: cc/claude-opus-4-8 and cx/gpt-5.5 on the sub routes
|
||||
# (gpt-5.5 404s on 0.3.60), a reworked WebSearch behind /api/v1/search, and
|
||||
# 3 months of cross-provider translator robustness.
|
||||
#
|
||||
# Original 0.3.60 pin reason (still holds): versions 0.3.60-0.3.96 regressed
|
||||
# cross-provider WebSearch (a Codex/Gemini primary delegating WebSearch saw
|
||||
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output).
|
||||
#
|
||||
# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens)
|
||||
# when translating Anthropic->OpenAI, which OpenAI's GPT-5 family rejects.
|
||||
# The fix lives in our /api/openai-passthrough proxy; see core/openai_passthrough.py
|
||||
# and sync_openai_api_key for how the translation lane is rerouted via an
|
||||
# `openai-compatible` provider-node that honors `baseUrl`.
|
||||
NINE_ROUTER_NPM_VERSION = "0.3.60"
|
||||
# REMAINING gate before flipping the prod default to 0.4.x: re-qualify
|
||||
# cross-provider WebSearch. The original 0.3.60 pin reason was that 0.3.60-0.3.96
|
||||
# regressed it (a Codex/Gemini primary delegating WebSearch saw
|
||||
# "claude-haiku-4-5-20251001 unavailable" or hallucinated output); 0.4.x reworked
|
||||
# it but that's unverified here. Also confirmed on 0.4.80: it STILL emits
|
||||
# `max_tokens` (not max_completion_tokens) on Anthropic->OpenAI, so our
|
||||
# /api/openai-passthrough rename (core/openai_passthrough.py + sync_openai_api_key,
|
||||
# routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary.
|
||||
NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60")
|
||||
|
||||
_process: subprocess.Popen | None = None
|
||||
|
||||
@@ -85,6 +83,83 @@ def is_running() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _nine_router_data_dir() -> str:
|
||||
"""Where 9Router persists machine-id + auth/cli-secret, the two files we
|
||||
hash into the /api/* auth token on 0.4.x. Mirrors 9Router's own default
|
||||
(DATA_DIR env, else ~/.9router on unix, %APPDATA%/9router on win) so we read
|
||||
the exact files it writes. We never relocate it: that would orphan a user's
|
||||
existing subscription connections."""
|
||||
env_dir = os.environ.get("DATA_DIR")
|
||||
if env_dir:
|
||||
return env_dir
|
||||
if os.name == "nt":
|
||||
base = os.environ.get("APPDATA") or os.path.join(
|
||||
os.path.expanduser("~"), "AppData", "Roaming"
|
||||
)
|
||||
return os.path.join(base, "9router")
|
||||
return os.path.join(os.path.expanduser("~"), ".9router")
|
||||
|
||||
|
||||
_cli_token_cache: str | None = None
|
||||
|
||||
|
||||
def cli_auth_token() -> str | None:
|
||||
"""The token 9Router 0.4.x checks in `x-9r-cli-token` on /api/* calls:
|
||||
sha256(machineId + "9r-cli-auth" + cliSecret)[:16]. machine-id is written
|
||||
at 9Router boot, cli-secret only lazily on its first self-call, so we create
|
||||
cli-secret ourselves (atomic O_EXCL, 0600, identical to 9Router's getter)
|
||||
when missing so connect/sync can auth before that self-call. Returns None on
|
||||
0.3.60 (no machine-id) or when 9Router isn't up, so the caller sends no
|
||||
header and the old auth-free path is untouched. Never raises."""
|
||||
global _cli_token_cache
|
||||
if _cli_token_cache:
|
||||
return _cli_token_cache
|
||||
if not is_running():
|
||||
return None
|
||||
try:
|
||||
data_dir = _nine_router_data_dir()
|
||||
try:
|
||||
with open(os.path.join(data_dir, "machine-id"), encoding="utf-8") as f:
|
||||
machine_id = f.read().strip()
|
||||
except OSError:
|
||||
return None # 0.3.60 layout, or 9Router hasn't written it yet
|
||||
if not machine_id:
|
||||
return None
|
||||
secret_path = os.path.join(data_dir, "auth", "cli-secret")
|
||||
try:
|
||||
with open(secret_path, encoding="utf-8") as f:
|
||||
cli_secret = f.read().strip()
|
||||
except OSError:
|
||||
cli_secret = ""
|
||||
if not cli_secret:
|
||||
cli_secret = secrets.token_hex(32)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(secret_path), exist_ok=True)
|
||||
# O_EXCL: if 9Router won the race and wrote first, read its value.
|
||||
fd = os.open(secret_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(cli_secret)
|
||||
except FileExistsError:
|
||||
with open(secret_path, encoding="utf-8") as f:
|
||||
cli_secret = f.read().strip()
|
||||
if not cli_secret:
|
||||
return None
|
||||
tok = hashlib.sha256(
|
||||
(machine_id + "9r-cli-auth" + cli_secret).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
_cli_token_cache = tok
|
||||
return tok
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def cli_auth_headers() -> dict[str, str]:
|
||||
"""`x-9r-cli-token` header for 9Router 0.4.x /api/* calls; empty dict on
|
||||
0.3.60 (no token), where the old auth-free endpoints still answer."""
|
||||
tok = cli_auth_token()
|
||||
return {"x-9r-cli-token": tok} if tok else {}
|
||||
|
||||
|
||||
def _find_9router_dir() -> str | None:
|
||||
"""Locate the bundled 9Router directory (works in both dev and packaged mode)."""
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
@@ -365,7 +440,7 @@ def stop():
|
||||
async def get_usage_stats(period: str = "all") -> dict | None:
|
||||
"""Get usage statistics from 9Router."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period})
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
@@ -391,7 +466,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No
|
||||
if not is_running():
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
async with httpx.AsyncClient(timeout=2.0, headers=cli_auth_headers()) as client:
|
||||
params: dict[str, Any] = {"page": 1, "pageSize": 5}
|
||||
if model_hint:
|
||||
params["model"] = model_hint
|
||||
@@ -422,7 +497,7 @@ async def get_providers() -> list[dict]:
|
||||
unwrap so callers always see a plain list of connection dicts.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/providers")
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
|
||||
@@ -7,7 +7,7 @@ as OpenSwarm-managed apikey connections. Talks to the already-running
|
||||
|
||||
import logging
|
||||
|
||||
from .process import NINE_ROUTER_API
|
||||
from .process import NINE_ROUTER_API, cli_auth_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +64,7 @@ async def _sync_apikey_provider(
|
||||
|
||||
existing = await _find_keyed_connection(provider, name)
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if api_key:
|
||||
payload = {
|
||||
"provider": provider,
|
||||
|
||||
@@ -10,7 +10,7 @@ spawns the subprocess (that's process.py's job).
|
||||
|
||||
import logging
|
||||
|
||||
from .process import NINE_ROUTER_API
|
||||
from .process import NINE_ROUTER_API, cli_auth_headers
|
||||
from .sync import (
|
||||
NINE_ROUTER_CLAUDE_PRO_NAME,
|
||||
NINE_ROUTER_OPENAI_KEYED_PREFIX,
|
||||
@@ -36,7 +36,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
|
||||
managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}"
|
||||
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/provider-nodes")
|
||||
existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or []
|
||||
except Exception as e:
|
||||
@@ -50,7 +50,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
|
||||
if not api_key:
|
||||
if existing_node:
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}")
|
||||
logger.info("9Router: removed OpenAI compat node (key cleared)")
|
||||
except Exception as e:
|
||||
@@ -66,7 +66,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
|
||||
}
|
||||
node_id: str | None = existing_node.get("id") if existing_node else None
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if existing_node:
|
||||
await client.put(
|
||||
f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}",
|
||||
@@ -100,7 +100,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None:
|
||||
"apiKey": api_key,
|
||||
"priority": 0,
|
||||
}
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if existing_conn:
|
||||
await client.patch(
|
||||
f"{NINE_ROUTER_API}/providers/{existing_conn['id']}",
|
||||
@@ -161,7 +161,7 @@ async def sync_custom_providers(providers: list) -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
r = await client.get(f"{NINE_ROUTER_API}/provider-nodes")
|
||||
existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or []
|
||||
except Exception as e:
|
||||
@@ -201,7 +201,7 @@ async def sync_custom_providers(providers: list) -> None:
|
||||
"type": "openai-compatible",
|
||||
}
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if node:
|
||||
await client.put(
|
||||
f"{NINE_ROUTER_API}/provider-nodes/{node['id']}",
|
||||
@@ -236,7 +236,7 @@ async def sync_custom_providers(providers: list) -> None:
|
||||
"apiKey": api_key,
|
||||
"priority": 0,
|
||||
}
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if existing_conn:
|
||||
await client.patch(
|
||||
f"{NINE_ROUTER_API}/providers/{existing_conn['id']}",
|
||||
@@ -259,7 +259,7 @@ async def sync_custom_providers(providers: list) -> None:
|
||||
if prefix in seen_prefixes:
|
||||
continue
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{node['id']}")
|
||||
logger.info(f"9Router: removed orphaned custom node {prefix}")
|
||||
except Exception as e:
|
||||
@@ -288,7 +288,7 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
|
||||
# is the direct-API id. Use `anthropic`.
|
||||
existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME)
|
||||
try:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0) as client:
|
||||
async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client:
|
||||
if bearer_token and proxy_url:
|
||||
payload = {
|
||||
"provider": "anthropic",
|
||||
|
||||
Reference in New Issue
Block a user