File size: 5,274 Bytes
2446f5f 500ef17 063d7d5 eab2c9c c90334d 063d7d5 56d0fcf c90334d 063d7d5 4988762 eab2c9c 2446f5f 063d7d5 5e1596b 063d7d5 e50ca24 063d7d5 6b64125 063d7d5 2446f5f eab2c9c c90334d 2446f5f 063d7d5 5e1596b 2446f5f 6a8ddc4 eab2c9c c90334d eab2c9c 5e1596b 063d7d5 d281b17 e50ca24 063d7d5 d281b17 eab2c9c 063d7d5 6b64125 6a8ddc4 eab2c9c 5e1596b eab2c9c 5e1596b eab2c9c 5e1596b eab2c9c 5e1596b 063d7d5 e50ca24 2446f5f 063d7d5 4988762 063d7d5 4988762 063d7d5 1c864be 063d7d5 5e1596b |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
import httpx
from fastapi import FastAPI, Request, HTTPException
from starlette.responses import StreamingResponse
from starlette.background import BackgroundTask
import os
import random
import logging
from contextlib import asynccontextmanager
# --- Logging Configuration ---
# Configure logging to display INFO level messages.
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Configuration ---
# The target URL is configurable via an environment variable.
TARGET_URL = os.getenv("TARGET_URL", "https://api.gmi-serving.com/v1/chat")
# Number of retries for specific error codes.
MAX_RETRIES = 5
# HTTP status codes that will trigger a retry.
RETRY_STATUS_CODES = {429, 502, 503, 504}
# --- Helper Function ---
def generate_random_ip():
"""Generates a random, valid-looking IPv4 address."""
return ".".join(str(random.randint(1, 254)) for _ in range(4))
# --- HTTPX Client Lifecycle Management ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Manages the lifecycle of the HTTPX client.
The client is created on startup and gracefully closed on shutdown.
NOTE: The client has no timeout, which is a deliberate choice for a proxy
that should not impose its own timeout limits on long-running requests.
"""
async with httpx.AsyncClient(base_url=TARGET_URL, timeout=None) as client:
app.state.http_client = client
yield
# Initialize the FastAPI app with the lifespan manager and disable default docs
app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
# --- Reverse Proxy Logic ---
async def _reverse_proxy(request: Request):
"""
Forwards a request to the target URL with retry logic and spoofed IP headers.
It allows for a user-provided Authorization header and logs the spoofed IP.
"""
client: httpx.AsyncClient = request.app.state.http_client
# Construct the URL for the outgoing request using the incoming path and query.
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
# --- Header Processing ---
request_headers = dict(request.headers)
request_headers.pop("host", None) # The 'host' header is managed by httpx.
authorization_header = request.headers.get("authorization")
random_ip = generate_random_ip()
logging.info(f"Client '{request.client.host}' is being proxied with spoofed IP: {random_ip}")
# Set the specific, required headers for the target API.
specific_headers = {
"accept": "application/json, text/plain, */*",
"accept-language": "en-US,en;q=0.9,ru;q=0.8",
"content-type": "application/json",
"origin": "https://console.gmicloud.ai",
"priority": "u=1, i",
"referer": "https://console.gmicloud.ai/playground/llm/qwen3-next-80b-a3b-thinking/a5c879a4-be0a-4621-95d3-42575238d9af?tab=playground",
"sec-ch-ua": '"Chromium";v="140", "Not=A?Brand";v="24", "Google Chrome";v="140"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"x-forwarded-for": random_ip,
"x-real-ip": random_ip,
}
request_headers.update(specific_headers)
if authorization_header:
request_headers["authorization"] = authorization_header
body = await request.body()
# --- Retry Logic ---
last_exception = None
for attempt in range(MAX_RETRIES):
try:
rp_req = client.build_request(
method=request.method,
url=url,
headers=request_headers,
content=body,
)
rp_resp = await client.send(rp_req, stream=True)
if rp_resp.status_code not in RETRY_STATUS_CODES or attempt == MAX_RETRIES - 1:
return StreamingResponse(
rp_resp.aiter_raw(),
status_code=rp_resp.status_code,
headers=rp_resp.headers,
background=BackgroundTask(rp_resp.aclose),
)
await rp_resp.aclose()
except httpx.ConnectError as e:
last_exception = e
logging.warning(f"Attempt {attempt + 1}/{MAX_RETRIES} failed with connection error: {e}")
# If all retry attempts failed, raise a final exception.
raise HTTPException(
status_code=502,
detail=f"Bad Gateway: Cannot connect to target service after {MAX_RETRIES} attempts. Last error: {last_exception}"
)
# --- API Endpoint ---
@app.api_route(
"/completions",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
)
async def chat_proxy_handler(request: Request):
"""
This endpoint captures requests specifically for the "/completions" path
and forwards them through the reverse proxy.
"""
return await _reverse_proxy(request)
@app.get("/")
async def health_check():
"""Provides a basic health check endpoint."""
return {"status": "ok", "proxying_endpoint": "/completions", "target": "TypeGPT"} |