Skip to content
Merged
54 changes: 10 additions & 44 deletions src/codebuddy_proxy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
resolve_backend_from_args,
)
from codebuddy_proxy.codebuddy_client_demo import CodeBuddyClient, CodeBuddyError
from codebuddy_proxy.billing_proxy import forward_billing

# 尝试导入高级功能模块(可选)
try:
Expand Down Expand Up @@ -823,55 +824,20 @@ async def health():
# 端点:/v1/checkin-status 与 /v1/checkin(每日签到透传)
# ============================================================================

async def _billing_post(path: str) -> dict[str, Any]:
"""用本地已登录会话调用官方 billing 端点并返回原始 JSON。

自动处理 401:先走 ensure_authenticated 刷新 token 再重试一次。
"""
state = get_state()
url = state.client.endpoint.rstrip("/") + path
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.post(url, json={}, headers=state.client.auth_headers())
if resp.status_code == 401:
# token 过期:触发一次刷新后重试
try:
await asyncio.to_thread(state.client.ensure_authenticated, False)
except Exception:
pass
resp = await client.post(url, json={}, headers=state.client.auth_headers())
try:
return resp.json()
except Exception:
return {"code": resp.status_code, "msg": f"non-JSON response (HTTP {resp.status_code})"}


@app.get("/v1/checkin-status")
@app.api_route("/v1/checkin-status", methods=["GET", "POST"])
async def checkin_status():
"""查询每日签到活动状态(透传官方 /v2/billing/meter/checkin-status)。"""
state = get_state()
try:
state.ensure_auth()
except Exception as e:
raise HTTPException(status_code=503, detail={"error": {"message": f"not authenticated: {e}", "type": "auth_error"}})
result = await _billing_post("/v2/billing/meter/checkin-status")
return {"status": "ok", "upstream": result}
"""Accept GET/POST locally; the real upstream billing status endpoint requires POST."""
return await forward_billing(
get_state(), "POST", "/v2/billing/meter/checkin-status", diagnostic
)


@app.post("/v1/checkin")
async def checkin():
"""执行每日签到(透传官方 /v2/billing/meter/daily-checkin)。

业务结果原样返回:code=0 且 data 非空为领取成功;已签到/活动结束等
业务拒绝通过上游 data.status / msg 区分,这里不做二次解释。
"""
state = get_state()
try:
state.ensure_auth()
except Exception as e:
raise HTTPException(status_code=503, detail={"error": {"message": f"not authenticated: {e}", "type": "auth_error"}})
result = await _billing_post("/v2/billing/meter/daily-checkin")
diagnostic("checkin_claim_result", code=result.get("code"), msg=result.get("msg"))
return {"status": "ok", "upstream": result}
"""POST daily check-in: preserve the upstream HTTP status and raw body."""
return await forward_billing(
get_state(), "POST", "/v2/billing/meter/daily-checkin", diagnostic
)


# ============================================================================
Expand Down
99 changes: 99 additions & 0 deletions src/codebuddy_proxy/billing_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Transparent, authenticated proxying of CodeBuddy billing endpoints.

Do not reinterpret upstream business codes, response bodies, or HTTP statuses.
The only responses synthesized here are for local network/transport failures.
"""

from __future__ import annotations

import asyncio
from typing import Any, Callable

import httpx
from fastapi.responses import JSONResponse, Response


# A shared lock prevents competing 401s from spending the same refresh token
# concurrently. Check token identity under the lock: another request may have
# already refreshed it while we waited.
_refresh_lock = asyncio.Lock()


def _upstream_response(response: httpx.Response) -> Response:
# Preserve raw bytes and Content-Type including original charset; do not
# turn upstream errors or non-JSON payloads into HTTP 200 JSON envelopes.
headers = {}
content_type = response.headers.get("content-type")
if content_type:
headers["content-type"] = content_type
return Response(
content=response.content,
status_code=response.status_code,
headers=headers,
)


def _transport_error(status: int, error_type: str) -> JSONResponse:
return JSONResponse(
status_code=status,
content={"error": {"type": error_type, "message": "Billing upstream request failed"}},
)


async def forward_billing(
state: Any,
method: str,
path: str,
diagnostic: Callable[..., Any],
) -> Response:
"""Send GET without body, POST with the required empty JSON payload.

A 401 triggers at most one retry, only after a successful refresh (or when
another concurrent request already updated the access token). If refresh
fails, preserve the original upstream 401 instead of blindly retrying.
"""
state.ensure_auth()
url = state.client.build_url(path)
method = method.upper()
kwargs = {"json": {}} if method == "POST" else {}

async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
try:
headers = state.client.auth_headers()
response = await client.request(method, url, headers=headers, **kwargs)

if response.status_code == 401:
async with _refresh_lock:
latest_headers = state.client.auth_headers()
old_token = headers.get("Authorization")
new_token = latest_headers.get("Authorization")

if new_token == old_token:
try:
refreshed = await asyncio.to_thread(state.client.refresh)
except Exception as exc:
diagnostic(
"billing_auth_refresh_failed",
path=path, reason=type(exc).__name__,
)
return _upstream_response(response)
if not refreshed:
diagnostic("billing_auth_refresh_failed", path=path, reason="not_refreshed")
return _upstream_response(response)
latest_headers = state.client.auth_headers()
new_token = latest_headers.get("Authorization")

if not new_token or new_token == old_token:
diagnostic("billing_auth_refresh_failed", path=path, reason="token_unchanged")
return _upstream_response(response)

# A fresh Authorization value, and no more than one retry.
response = await client.request(method, url, headers=latest_headers, **kwargs)
except httpx.TimeoutException:
diagnostic("billing_upstream_failed", path=path, reason="timeout")
return _transport_error(504, "upstream_timeout")
except httpx.RequestError as exc:
diagnostic("billing_upstream_failed", path=path, reason=type(exc).__name__)
return _transport_error(502, "upstream_connection_error")

return _upstream_response(response)
Loading
Loading