Skip to content

Commit 001a71e

Browse files
committed
fix: hold FlowContext for full stream lifetime via finally block to prevent WARP rotation mid-stream; feat: add Anthropic /v1/messages endpoint; docs: update README with Anthropic, custom proxy, and corrected opencode.jsonc config
1 parent 59a14f4 commit 001a71e

2 files changed

Lines changed: 145 additions & 74 deletions

File tree

README.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ A microservice-architected Cloudflare WARP IP rotator and proxy server for OpenC
1515

1616
- **Guaranteed IP Diversity**: Uses `warp-cli registration delete & new` cycles to request a distinct public IP address on every rotation.
1717
- **Microservices Architecture**: Decoupled `proxy-server` (FastAPI) and `warp-rotator` (Cloudflare WARP daemon) services built with Docker Compose.
18-
- **Clean Management Dashboard**: Lightweight Web UI built with Tailwind CSS displaying active connections, current location, token statistics, and manual rotation controls.
18+
- **Clean Management Dashboard**: Lightweight Web UI displaying active connections, current location, token statistics, and manual rotation controls.
1919
- **SQLite Data Persistence**: Stores token consumption, model request counts, and historical IP rotation logs on disk.
2020
- **USD Savings Calculator**: Estimates cost savings per model based on prompt and completion token rates.
2121
- **Table Pagination**: Built-in 5-item pagination for model usage and IP rotation log tables.
22-
- **Active Flow Locking**: Protects active Server-Sent Events (SSE) streams from being interrupted during an IP rotation.
22+
- **Active Flow Locking**: Protects active SSE streams from being interrupted during IP rotation — `_active_flows_count` is held for the **full lifetime of the generator**, not just until `return`.
23+
- **Smart Rate Limit Detection**: Distinguishes IP-level blocks (triggers WARP rotation) from model-level quota limits (skips rotation to prevent TCP socket teardown).
24+
- **Anthropic API Compatibility**: Native `/v1/messages` endpoint for Claude clients and the Vercel AI SDK `@ai-sdk/anthropic` provider.
25+
- **Custom Proxy Pool Support**: Round-robin outbound proxy pool via `data/proxies.txt` or `PROXY_LIST` environment variable.
2326

2427
---
2528

@@ -120,14 +123,18 @@ python manager.py
120123

121124
## Configuration
122125

123-
To use the local proxy server within OpenCode, update your configuration file located at `~/.config/opencode/opencode.jsonc`:
126+
### OpenAI-Compatible Provider (Default)
127+
128+
To use the local proxy server within OpenCode, update your configuration file at `~/.config/opencode/opencode.jsonc`:
124129

125130
```jsonc
126131
{
127132
"provider": {
128133
"opencode-zen-local": {
134+
"npm": "@ai-sdk/openai-compatible",
129135
"options": {
130-
"baseURL": "http://127.0.0.1:8000/v1"
136+
"baseURL": "http://127.0.0.1:8000/v1",
137+
"apiKey": "any"
131138
},
132139
"name": "OpenCode Zen Local Proxy"
133140
}
@@ -139,11 +146,53 @@ To use the local proxy server within OpenCode, update your configuration file lo
139146
140147
---
141148

149+
### Anthropic API Provider
150+
151+
The proxy exposes a native Anthropic-compatible `/v1/messages` endpoint. To use it with OpenCode's Anthropic provider:
152+
153+
```jsonc
154+
{
155+
"provider": {
156+
"my-anthropic-proxy": {
157+
"npm": "@ai-sdk/anthropic",
158+
"options": {
159+
"baseURL": "http://127.0.0.1:8000",
160+
"apiKey": "any"
161+
}
162+
}
163+
}
164+
}
165+
```
166+
167+
> Requests sent to `/v1/messages` are translated to OpenAI format internally and routed through the same WARP-protected upstream.
168+
169+
---
170+
171+
### Custom Outbound Proxy Pool
172+
173+
If you want to use your own HTTP/SOCKS5 proxies instead of (or in addition to) Cloudflare WARP:
174+
175+
**Option 1 — File:** Create `data/proxies.txt` with one proxy per line:
176+
```
177+
http://user:pass@proxy1.example.com:8080
178+
socks5://proxy2.example.com:1080
179+
```
180+
181+
**Option 2 — Environment variable:**
182+
```bash
183+
PROXY_LIST="http://proxy1:8080,socks5://proxy2:1080" docker compose up -d
184+
```
185+
186+
The proxy pool rotates in round-robin order across all outbound requests.
187+
188+
---
189+
142190
## API Endpoints Reference
143191

144192
| Endpoint | Method | Description |
145193
| :--- | :--- | :--- |
146194
| `/v1/chat/completions` | `POST` | OpenAI-compatible chat completion endpoint with automatic retry and IP rotation. |
195+
| `/v1/messages` | `POST` | Anthropic-compatible endpoint (`/v1/messages`) for Claude clients and `@ai-sdk/anthropic`. |
147196
| `/v1/models` | `GET` | Returns list of currently discovered active free models. |
148197
| `/dashboard` | `GET` | Renders the HTML Web Management Dashboard. |
149198
| `/metrics` | `GET` | Returns structured JSON metrics including verified IP, uptime, and request counters. |

server.py

Lines changed: 92 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -675,30 +675,45 @@ def get_realistic_headers() -> Dict[str, str]:
675675
}
676676

677677
async def stream_openai_response(response, model_name: str) -> AsyncGenerator[bytes, None]:
678+
"""
679+
Raw byte passthrough SSE stream generator.
680+
CRITICAL: FlowContext is held for the ENTIRE duration of streaming so that
681+
rotate_warp() in rotator.py sees _active_flows_count > 0 and skips IP rotation
682+
while a response is still being streamed to the client.
683+
"""
678684
loop = asyncio.get_event_loop()
679-
with FlowContext():
680-
try:
681-
def get_next_chunk(iter_content):
682-
try:
683-
return next(iter_content)
684-
except StopIteration:
685-
return None
686-
687-
# Stream raw bytes chunks directly from HTTP response (Do not split lines or strip whitespace/newlines)
688-
chunk_iter = response.iter_content(chunk_size=4096)
689-
690-
while True:
691-
chunk = await loop.run_in_executor(None, get_next_chunk, chunk_iter)
692-
if chunk is None or not chunk:
693-
break
694-
695-
# Raw chunk passthrough to client
696-
yield chunk
685+
global _active_flows_count
697686

698-
yield b"\ndata: [DONE]\n\n"
699-
except Exception as e:
700-
log.error(f"Stream exception caught: {e}")
701-
yield b"\ndata: [DONE]\n\n"
687+
# Manually increment flow counter — keeps it raised until generator is exhausted/closed
688+
with _flow_lock:
689+
_active_flows_count += 1
690+
691+
try:
692+
def get_next_chunk(iter_content):
693+
try:
694+
return next(iter_content)
695+
except StopIteration:
696+
return None
697+
698+
chunk_iter = response.iter_content(chunk_size=4096)
699+
700+
while True:
701+
chunk = await loop.run_in_executor(None, get_next_chunk, chunk_iter)
702+
if chunk is None or not chunk:
703+
break
704+
yield chunk
705+
706+
yield b"\ndata: [DONE]\n\n"
707+
except GeneratorExit:
708+
# Client disconnected — still need to decrement
709+
pass
710+
except Exception as e:
711+
log.error(f"Stream exception caught: {e}")
712+
yield b"\ndata: [DONE]\n\n"
713+
finally:
714+
# ALWAYS decrement when stream ends, regardless of how it ended
715+
with _flow_lock:
716+
_active_flows_count = max(0, _active_flows_count - 1)
702717

703718
@app.get("/dashboard", response_class=HTMLResponse)
704719
async def dashboard():
@@ -787,59 +802,66 @@ async def chat_completions(raw_request: Request):
787802

788803
for attempt in range(1, MAX_RETRIES_ON_429 + 1):
789804
try:
790-
with FlowContext():
791-
from curl_cffi import requests as cffi_requests
792-
import random
793-
794-
time.sleep(random.uniform(0.1, 0.3))
795-
proxies = get_next_outbound_proxy()
796-
797-
response = cffi_requests.post(
798-
TARGET_ZEN_URL,
799-
json=payload,
800-
headers=headers,
801-
impersonate="chrome124",
802-
stream=is_stream,
803-
proxies=proxies,
804-
timeout=120
805-
)
806-
807-
# Check HTTP status code for rate-limit or upstream error
808-
if response.status_code == 429 or response.status_code >= 500:
809-
metrics["rate_limited_requests"] += 1
810-
err_text = response.text.lower()
811-
812-
# Distinguish Model/Provider-level limits vs. IP-level blocks
813-
is_model_specific_limit = any(k in err_text for k in ["model_rate_limit", "quota_exceeded", "per_model_limit", "credit_balance"])
814-
815-
import random
816-
delay = (INITIAL_BACKOFF * (2 ** (attempt - 1))) + random.uniform(0.5, 1.5)
817-
818-
if is_model_specific_limit:
819-
log.warning(f"Model-level limit for '{current_model}'. Skipping IP rotation to prevent socket teardown. Retrying in {delay:.2f}s...")
820-
time.sleep(delay)
821-
continue
822-
else:
823-
log.warning(f"HTTP {response.status_code} (IP/Network block) for '{current_model}'. Triggering IP Rotation & Retrying in {delay:.2f}s...")
824-
rotate_warp(reason=f"HTTP {response.status_code} on {current_model}")
825-
time.sleep(delay)
826-
continue
827-
828-
metrics["successful_requests"] += 1
829-
830-
if is_stream:
831-
track_token_usage(current_model, prompt_tokens=100, completion_tokens=150)
832-
return StreamingResponse(
833-
stream_openai_response(response, current_model),
834-
media_type="text/event-stream"
835-
)
805+
from curl_cffi import requests as cffi_requests
806+
import random
807+
808+
time.sleep(random.uniform(0.1, 0.3))
809+
proxies = get_next_outbound_proxy()
810+
811+
# FlowContext is NOT used here for streaming — the generator manages it internally
812+
# For non-streaming we use it to block rotation during the entire request
813+
response = cffi_requests.post(
814+
TARGET_ZEN_URL,
815+
json=payload,
816+
headers=headers,
817+
impersonate="chrome124",
818+
stream=is_stream,
819+
proxies=proxies,
820+
timeout=120
821+
)
822+
823+
# Check HTTP status code for rate-limit or upstream error
824+
if response.status_code == 429 or response.status_code >= 500:
825+
metrics["rate_limited_requests"] += 1
826+
err_text = response.text.lower()
827+
828+
# Distinguish Model/Provider-level limits vs. IP-level blocks
829+
is_model_specific_limit = any(k in err_text for k in ["model_rate_limit", "quota_exceeded", "per_model_limit", "credit_balance", "insufficient_quota"])
830+
831+
delay = (INITIAL_BACKOFF * (2 ** (attempt - 1))) + random.uniform(0.5, 1.5)
832+
833+
if is_model_specific_limit:
834+
log.warning(f"Model-level limit for '{current_model}'. Skipping IP rotation to prevent socket teardown. Retrying in {delay:.2f}s...")
835+
time.sleep(delay)
836+
continue
836837
else:
838+
log.warning(f"HTTP {response.status_code} (IP/Network block) for '{current_model}'. Triggering IP Rotation & Retrying in {delay:.2f}s...")
839+
rotate_warp(reason=f"HTTP {response.status_code} on {current_model}")
840+
time.sleep(delay)
841+
continue
842+
843+
metrics["successful_requests"] += 1
844+
845+
if is_stream:
846+
track_token_usage(current_model, prompt_tokens=100, completion_tokens=150)
847+
# stream_openai_response manages FlowContext internally via finally block
848+
return StreamingResponse(
849+
stream_openai_response(response, current_model),
850+
media_type="text/event-stream",
851+
headers={
852+
"Cache-Control": "no-cache",
853+
"X-Accel-Buffering": "no",
854+
"Connection": "keep-alive",
855+
}
856+
)
857+
else:
858+
with FlowContext():
837859
try:
838860
res_json = response.json()
839861
usage = res_json.get("usage", {})
840862
track_token_usage(
841-
current_model,
842-
prompt_tokens=usage.get("prompt_tokens", 50),
863+
current_model,
864+
prompt_tokens=usage.get("prompt_tokens", 50),
843865
completion_tokens=usage.get("completion_tokens", 100)
844866
)
845867
return JSONResponse(content=res_json)

0 commit comments

Comments
 (0)