| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- """HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
- import base64
- import json
- import os
- import sys
- import time
- import urllib.error
- import urllib.parse
- import urllib.request
- from datetime import datetime, timezone
- from creds import bucket_name_for, get_bucket, load_creds, resolve_api_url, save_creds
- from errors import ApiError, WpError
- from sites import ONLINE_URLS, SITES, site_label
- TOKEN_REFRESH_MARGIN = 3600
- DEFAULT_TIMEOUT = 30
- WRITE_TIMEOUT = 120
- DEFAULT_BATCH = 50
- def mask(token):
- if not token:
- return "(无)"
- if len(token) <= 16:
- return token[:4] + "…"
- return token[:8] + "…" + token[-4:]
- def jwt_payload(token):
- """不验签地读出 JWT payload,仅用于显示有效期。"""
- try:
- part = token.split(".")[1]
- part += "=" * (-len(part) % 4)
- return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
- except Exception:
- return {}
- def token_expiry(token):
- exp = jwt_payload(token).get("exp")
- return int(exp) if isinstance(exp, (int, float)) else None
- def fmt_ts(ts):
- if not ts:
- return "未知"
- return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
- def note(msg):
- print(msg, file=sys.stderr)
- def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
- """发一个 JSON 请求,返回解析后的响应体(dict)。
- 网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
- HTTP 层失败抛 ApiError,带上服务端 message。
- """
- # 路径里可能有巴利词(parivāsa),urllib 只接受 ASCII,必须先百分号编码
- url = api_url + "/" + urllib.parse.quote(path.lstrip("/"), safe="/")
- if query:
- url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
- data = None
- headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
- if body is not None:
- data = json.dumps(body, ensure_ascii=False).encode("utf-8")
- headers["Content-Type"] = "application/json"
- if token:
- headers["Authorization"] = "Bearer " + token
- req = urllib.request.Request(url, data=data, headers=headers, method=method)
- try:
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- raw = resp.read().decode("utf-8", "replace")
- status = resp.status
- except urllib.error.HTTPError as exc:
- raw = exc.read().decode("utf-8", "replace")
- status = exc.code
- payload = safe_json(raw)
- message = payload.get("message") if isinstance(payload, dict) else None
- raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
- payload = safe_json(raw)
- if not isinstance(payload, dict):
- raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
- if not payload.get("ok", False):
- raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
- return payload.get("data")
- def safe_json(raw):
- try:
- return json.loads(raw)
- except ValueError:
- return None
- class Client:
- """按站点收发请求,并在线上地址之间做出声的 fallback。"""
- def __init__(self, api_url, source, creds, allow_fallback=True):
- self.api_url = api_url
- self.source = source
- self.creds = creds
- self.bucket_name = bucket_name_for(api_url)
- self.bucket = get_bucket(creds, self.bucket_name, api_url)
- self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
- # -- 凭据 ---------------------------------------------------------------
- @property
- def user_token(self):
- token = (self.bucket.get("user") or {}).get("token")
- if not token:
- raise WpError(
- "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
- " ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
- )
- return token
- @property
- def model(self):
- model = self.bucket.get("model") or {}
- if not model.get("token"):
- raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
- return model
- def save(self):
- save_creds(self.creds)
- # -- 请求 ---------------------------------------------------------------
- def fallback_order(self):
- """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
- cur = next((s for s in SITES if s["url"] == self.api_url), None)
- if not cur:
- return []
- others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
- others.sort(
- key=lambda s: (
- 0 if s["version"] == cur["version"] else 1,
- 0 if s["domain"] == cur["domain"] else 1,
- )
- )
- return [s["url"] for s in others]
- def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
- urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
- last = None
- for idx, url in enumerate(urls):
- try:
- data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
- # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
- last = exc
- reason = getattr(exc, "reason", exc)
- if idx + 1 < len(urls):
- note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
- continue
- if url != self.api_url:
- # fallback 成功后本次会话都用它,但不写回凭据文件
- note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
- self.api_url = url
- return data
- raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
- def api_note(self):
- src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
- return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
- def make_client(args, allow_fallback=True):
- creds = load_creds()
- api_url, source = resolve_api_url(getattr(args, "api", None), creds)
- return Client(api_url, source, creds, allow_fallback=allow_fallback)
|