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"}