391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""
|
|
SuperGrok Local Proxy - CLI
|
|
本地 OpenAI 兼容代理,通过 xAI OAuth (PKCE) 登录 SuperGrok,自动管理 token 并转发请求。
|
|
|
|
用法:
|
|
python assets/supergrok_proxy.py login
|
|
python assets/supergrok_proxy.py serve --port 15433
|
|
python assets/supergrok_proxy.py models
|
|
python assets/supergrok_proxy.py test --model grok-4.5
|
|
|
|
GenericAgent/mykey 配置示例:
|
|
native_oai_config_supergrok_proxy = {
|
|
'name': 'supergrok',
|
|
'apikey': 'dummy',
|
|
'apibase': 'http://127.0.0.1:15433/v1',
|
|
'model': 'grok-4.3',
|
|
'max_retries': 3,
|
|
'read_timeout': 600,
|
|
'stream': False,
|
|
}
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import webbrowser
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer
|
|
from urllib.parse import urlencode, urlparse, parse_qs
|
|
|
|
import requests
|
|
|
|
XAI_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
|
XAI_AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize'
|
|
XAI_TOKEN_URL = 'https://auth.x.ai/oauth2/token'
|
|
XAI_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
|
|
XAI_CALLBACK_PORT = 56121
|
|
XAI_CALLBACK_URI = f'http://127.0.0.1:{XAI_CALLBACK_PORT}/callback'
|
|
XAI_API_BASE = 'https://api.x.ai/v1'
|
|
DEFAULT_STORE = os.path.join(os.path.expanduser('~'), '.genericagent', 'xai_oauth.json')
|
|
DEFAULT_PROXY_PORT = 15433
|
|
REFRESH_MARGIN = 120
|
|
|
|
|
|
def log(msg):
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
def b64url(raw: bytes) -> str:
|
|
return base64.urlsafe_b64encode(raw).decode().rstrip('=')
|
|
|
|
|
|
def make_proxies(proxy: str | None):
|
|
if not proxy:
|
|
return None
|
|
return {'http': proxy, 'https': proxy}
|
|
|
|
|
|
class TokenManager:
|
|
def __init__(self, store_path=DEFAULT_STORE, proxy='http://127.0.0.1:2082'):
|
|
self.store_path = os.path.expanduser(store_path)
|
|
self.proxy = proxy or None
|
|
self.proxies = make_proxies(self.proxy)
|
|
self.lock = threading.Lock()
|
|
self.access_token = None
|
|
self.refresh_token = None
|
|
self.expires_at = 0.0
|
|
self.load()
|
|
|
|
def load(self):
|
|
try:
|
|
with open(self.store_path, encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
self.access_token = data.get('access_token')
|
|
self.refresh_token = data.get('refresh_token')
|
|
self.expires_at = float(data.get('expires_at') or 0)
|
|
if self.access_token:
|
|
remain = int(self.expires_at - time.time())
|
|
log(f"Token loaded: ***{self.access_token[-6:]} expires_in={remain}s")
|
|
except FileNotFoundError:
|
|
log(f"No saved token: {self.store_path}")
|
|
except Exception as e:
|
|
log(f"Failed to load token: {e}")
|
|
|
|
def save(self, data):
|
|
os.makedirs(os.path.dirname(self.store_path), exist_ok=True)
|
|
with open(self.store_path, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
try:
|
|
os.chmod(self.store_path, 0o600)
|
|
except OSError:
|
|
pass
|
|
log(f"Token saved: {self.store_path}")
|
|
|
|
def login(self, open_browser=True, timeout=300):
|
|
verifier = b64url(secrets.token_bytes(32))
|
|
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
|
|
state = secrets.token_urlsafe(24)
|
|
nonce = secrets.token_urlsafe(24)
|
|
params = {
|
|
'client_id': XAI_CLIENT_ID,
|
|
'redirect_uri': XAI_CALLBACK_URI,
|
|
'response_type': 'code',
|
|
'scope': XAI_SCOPE,
|
|
'state': state,
|
|
'nonce': nonce,
|
|
'code_challenge': challenge,
|
|
'code_challenge_method': 'S256',
|
|
'plan': 'generic',
|
|
'referrer': 'generic-agent',
|
|
}
|
|
auth_url = f'{XAI_AUTHORIZE_URL}?{urlencode(params)}'
|
|
result = {}
|
|
|
|
class CallbackHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
qs = parse_qs(urlparse(self.path).query)
|
|
result.update({k: v[0] for k, v in qs.items() if v})
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
self.end_headers()
|
|
self.wfile.write('SuperGrok login complete. You can close this tab.'.encode('utf-8'))
|
|
|
|
httpd = HTTPServer(('127.0.0.1', XAI_CALLBACK_PORT), CallbackHandler)
|
|
httpd.timeout = 1
|
|
log(f"Callback listening: {XAI_CALLBACK_URI}")
|
|
log("Open this URL to login xAI/SuperGrok:")
|
|
print(auth_url, flush=True)
|
|
if open_browser:
|
|
try:
|
|
webbrowser.open(auth_url)
|
|
except Exception as e:
|
|
log(f"Browser open failed: {e}")
|
|
|
|
deadline = time.time() + timeout
|
|
while not result.get('code') and time.time() < deadline:
|
|
httpd.handle_request()
|
|
httpd.server_close()
|
|
|
|
if not result.get('code'):
|
|
raise RuntimeError(f'OAuth timeout after {timeout}s')
|
|
if result.get('state') and result['state'] != state:
|
|
raise RuntimeError('OAuth state mismatch')
|
|
|
|
log("Exchanging authorization code for token...")
|
|
resp = requests.post(XAI_TOKEN_URL, data={
|
|
'grant_type': 'authorization_code',
|
|
'client_id': XAI_CLIENT_ID,
|
|
'code': result['code'],
|
|
'redirect_uri': XAI_CALLBACK_URI,
|
|
'code_verifier': verifier,
|
|
'code_challenge': challenge,
|
|
'code_challenge_method': 'S256',
|
|
}, proxies=self.proxies, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
data['expires_at'] = time.time() + int(data.get('expires_in', 3600))
|
|
self.access_token = data.get('access_token')
|
|
self.refresh_token = data.get('refresh_token')
|
|
self.expires_at = data['expires_at']
|
|
self.save(data)
|
|
log("Login OK")
|
|
return self.access_token
|
|
|
|
def get_token(self):
|
|
with self.lock:
|
|
if self.access_token and time.time() < self.expires_at - REFRESH_MARGIN:
|
|
return self.access_token
|
|
return self.refresh()
|
|
|
|
def refresh(self):
|
|
if not self.refresh_token:
|
|
raise RuntimeError('No refresh_token. Run login first.')
|
|
log("Refreshing token...")
|
|
resp = requests.post(XAI_TOKEN_URL, data={
|
|
'grant_type': 'refresh_token',
|
|
'client_id': XAI_CLIENT_ID,
|
|
'refresh_token': self.refresh_token,
|
|
}, proxies=self.proxies, timeout=30)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
data['expires_at'] = time.time() + int(data.get('expires_in', 3600))
|
|
if 'refresh_token' not in data:
|
|
data['refresh_token'] = self.refresh_token
|
|
self.access_token = data.get('access_token')
|
|
self.refresh_token = data.get('refresh_token')
|
|
self.expires_at = data['expires_at']
|
|
self.save(data)
|
|
log(f"Refresh OK expires_in={int(self.expires_at - time.time())}s")
|
|
return self.access_token
|
|
|
|
|
|
def make_proxy_handler(token_mgr: TokenManager):
|
|
class ProxyHandler(BaseHTTPRequestHandler):
|
|
protocol_version = 'HTTP/1.1'
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass
|
|
|
|
def _send_json_error(self, code, msg):
|
|
body = json.dumps({'error': {'message': str(msg), 'type': 'supergrok_proxy_error'}}).encode('utf-8')
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _target_url(self):
|
|
path = self.path
|
|
if path.startswith('/v1/'):
|
|
path = path[3:]
|
|
elif path == '/v1':
|
|
path = ''
|
|
return XAI_API_BASE + path
|
|
|
|
def do_GET(self):
|
|
try:
|
|
token = token_mgr.get_token()
|
|
target = self._target_url()
|
|
log(f"GET {self.path} -> {target}")
|
|
resp = requests.get(target, headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
|
|
}, proxies=token_mgr.proxies, timeout=60)
|
|
content = resp.content
|
|
self.send_response(resp.status_code)
|
|
self.send_header('Content-Type', resp.headers.get('content-type', 'application/json'))
|
|
self.send_header('Content-Length', str(len(content)))
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
log(f"RESP {resp.status_code}")
|
|
except Exception as e:
|
|
log(f"GET error: {e}")
|
|
self._send_json_error(502, e)
|
|
|
|
def do_POST(self):
|
|
try:
|
|
length = int(self.headers.get('Content-Length', 0))
|
|
raw = self.rfile.read(length) if length else b'{}'
|
|
try:
|
|
body = json.loads(raw.decode('utf-8')) if raw else {}
|
|
except Exception:
|
|
body = None
|
|
stream = bool(body.get('stream')) if isinstance(body, dict) else False
|
|
model = body.get('model', '?') if isinstance(body, dict) else '?'
|
|
token = token_mgr.get_token()
|
|
target = self._target_url()
|
|
log(f"POST {self.path} model={model} stream={stream}")
|
|
resp = requests.post(target, headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Content-Type': self.headers.get('Content-Type', 'application/json'),
|
|
'Accept': 'text/event-stream' if stream else 'application/json',
|
|
'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
|
|
'x-request-id': str(uuid.uuid4()),
|
|
}, data=raw, proxies=token_mgr.proxies, timeout=180, stream=stream)
|
|
|
|
ctype = resp.headers.get('content-type', '')
|
|
if stream or 'text/event-stream' in ctype:
|
|
self.send_response(resp.status_code)
|
|
self.send_header('Content-Type', 'text/event-stream')
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self.end_headers()
|
|
for chunk in resp.iter_content(chunk_size=None):
|
|
if chunk:
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
else:
|
|
content = resp.content
|
|
self.send_response(resp.status_code)
|
|
self.send_header('Content-Type', ctype or 'application/json')
|
|
self.send_header('Content-Length', str(len(content)))
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
if resp.status_code >= 400:
|
|
log(f"RESP {resp.status_code} {'' if stream else resp.text[:500]}")
|
|
else:
|
|
log(f"RESP {resp.status_code}")
|
|
except Exception as e:
|
|
log(f"POST error: {e}")
|
|
self._send_json_error(502, e)
|
|
|
|
return ProxyHandler
|
|
|
|
|
|
def cmd_login(args):
|
|
TokenManager(args.store, args.upstream_proxy).login(open_browser=not args.no_browser, timeout=args.timeout)
|
|
|
|
|
|
def cmd_refresh(args):
|
|
TokenManager(args.store, args.upstream_proxy).refresh()
|
|
|
|
|
|
def cmd_serve(args):
|
|
mgr = TokenManager(args.store, args.upstream_proxy)
|
|
if not mgr.access_token:
|
|
log("No token found. Starting login first...")
|
|
mgr.login(open_browser=not args.no_browser, timeout=args.timeout)
|
|
else:
|
|
mgr.get_token()
|
|
server = ThreadingHTTPServer((args.host, args.port), make_proxy_handler(mgr))
|
|
log(f"Serving OpenAI-compatible proxy at http://{args.host}:{args.port}/v1")
|
|
log("Press Ctrl+C to stop.")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log("Stopping...")
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
def cmd_models(args):
|
|
mgr = TokenManager(args.store, args.upstream_proxy)
|
|
token = mgr.get_token()
|
|
resp = requests.get(f'{XAI_API_BASE}/models', headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
|
|
}, proxies=mgr.proxies, timeout=60)
|
|
print(resp.status_code)
|
|
print(resp.text)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def cmd_test(args):
|
|
mgr = TokenManager(args.store, args.upstream_proxy)
|
|
token = mgr.get_token()
|
|
payload = {'model': args.model, 'messages': [{'role': 'user', 'content': args.prompt}], 'stream': False}
|
|
resp = requests.post(f'{XAI_API_BASE}/chat/completions', headers={
|
|
'Authorization': f'Bearer {token}',
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0',
|
|
}, json=payload, proxies=mgr.proxies, timeout=120)
|
|
print(resp.status_code)
|
|
print(resp.text)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def build_parser():
|
|
p = argparse.ArgumentParser(description='SuperGrok xAI OAuth local OpenAI-compatible proxy')
|
|
p.add_argument('--store', default=DEFAULT_STORE, help=f'token store path, default: {DEFAULT_STORE}')
|
|
p.add_argument('--upstream-proxy', default='http://127.0.0.1:2082', help='proxy for xAI auth/api; empty disables')
|
|
# Defaults for zero-argument mode: run once, login if needed, then serve.
|
|
p.set_defaults(func=cmd_serve, host='127.0.0.1', port=DEFAULT_PROXY_PORT, no_browser=False, timeout=300)
|
|
sub = p.add_subparsers(dest='cmd')
|
|
|
|
sp = sub.add_parser('login', help='open browser and login xAI OAuth')
|
|
sp.add_argument('--no-browser', action='store_true')
|
|
sp.add_argument('--timeout', type=int, default=300)
|
|
sp.set_defaults(func=cmd_login)
|
|
|
|
sp = sub.add_parser('refresh', help='refresh saved token')
|
|
sp.set_defaults(func=cmd_refresh)
|
|
|
|
sp = sub.add_parser('serve', help='serve local OpenAI-compatible proxy')
|
|
sp.add_argument('--host', default='127.0.0.1')
|
|
sp.add_argument('--port', type=int, default=DEFAULT_PROXY_PORT)
|
|
sp.add_argument('--no-browser', action='store_true')
|
|
sp.add_argument('--timeout', type=int, default=300)
|
|
sp.set_defaults(func=cmd_serve)
|
|
|
|
sp = sub.add_parser('models', help='call /v1/models directly')
|
|
sp.set_defaults(func=cmd_models)
|
|
|
|
sp = sub.add_parser('test', help='send one chat completion request directly')
|
|
sp.add_argument('--model', default='grok-4.3')
|
|
sp.add_argument('--prompt', default='Say OK in one word.')
|
|
sp.set_defaults(func=cmd_test)
|
|
return p
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_parser().parse_args(argv)
|
|
if args.upstream_proxy == '':
|
|
args.upstream_proxy = None
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|