yiranxiaohui Claude Opus 4.7 (1M context) commited on
Commit
c0ec55b
·
1 Parent(s): 54a879d

feat(proxy): 支持为 chatgpt.com 上游请求配置代理

Browse files

- 新增 proxy_service 持久化 data/proxy_config.json,支持环境变量 CHATGPT2API_PROXY 初始化
- account_service / image_service 的 Session 在启用时注入 http/socks 代理
- 新增 /api/proxy GET/POST 和 /api/proxy/test 路由,URL 回显时遮蔽密码
- 设置页新增「上游代理配置」卡片,支持启用开关、地址编辑、连通测试

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

.gitignore CHANGED
@@ -14,4 +14,6 @@ config.json
14
  edit
15
  test
16
 
17
- docker-compose-local.yml
 
 
 
14
  edit
15
  test
16
 
17
+ docker-compose-local.yml
18
+
19
+ .claude/settings.local.json
services/account_service.py CHANGED
@@ -12,6 +12,7 @@ from datetime import datetime
12
  from curl_cffi.requests import Session
13
 
14
  from services.config import config
 
15
 
16
 
17
  class AccountService:
@@ -399,6 +400,7 @@ class AccountService:
399
  headers, impersonate = self._build_remote_headers(access_token)
400
  print(f"[account-refresh] start {access_token[:12]}...")
401
  session = Session(impersonate=impersonate, verify=True)
 
402
  session.headers.update(headers)
403
  try:
404
  with ThreadPoolExecutor(max_workers=2) as executor:
 
12
  from curl_cffi.requests import Session
13
 
14
  from services.config import config
15
+ from services.proxy_service import apply_proxy_to_session
16
 
17
 
18
  class AccountService:
 
400
  headers, impersonate = self._build_remote_headers(access_token)
401
  print(f"[account-refresh] start {access_token[:12]}...")
402
  session = Session(impersonate=impersonate, verify=True)
403
+ apply_proxy_to_session(session)
404
  session.headers.update(headers)
405
  try:
406
  with ThreadPoolExecutor(max_workers=2) as executor:
services/api.py CHANGED
@@ -14,6 +14,7 @@ from services.account_service import account_service
14
  from services.chatgpt_service import ChatGPTService
15
  from services.config import config
16
  from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
 
17
  from services.sub2api_service import (
18
  list_remote_accounts as sub2api_list_remote_accounts,
19
  sub2api_config,
@@ -111,6 +112,15 @@ class Sub2APIImportRequest(BaseModel):
111
  account_ids: list[str] = Field(default_factory=list)
112
 
113
 
 
 
 
 
 
 
 
 
 
114
  def build_model_item(model_id: str) -> dict[str, object]:
115
  return {
116
  "id": model_id,
@@ -541,6 +551,44 @@ def create_app() -> FastAPI:
541
  raise HTTPException(status_code=404, detail={"error": "server not found"})
542
  return {"import_job": server.get("import_job")}
543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  app.include_router(router)
545
 
546
  @app.get("/{full_path:path}", include_in_schema=False)
 
14
  from services.chatgpt_service import ChatGPTService
15
  from services.config import config
16
  from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
17
+ from services.proxy_service import proxy_config, test_proxy
18
  from services.sub2api_service import (
19
  list_remote_accounts as sub2api_list_remote_accounts,
20
  sub2api_config,
 
112
  account_ids: list[str] = Field(default_factory=list)
113
 
114
 
115
+ class ProxyUpdateRequest(BaseModel):
116
+ enabled: bool | None = None
117
+ url: str | None = None
118
+
119
+
120
+ class ProxyTestRequest(BaseModel):
121
+ url: str = ""
122
+
123
+
124
  def build_model_item(model_id: str) -> dict[str, object]:
125
  return {
126
  "id": model_id,
 
551
  raise HTTPException(status_code=404, detail={"error": "server not found"})
552
  return {"import_job": server.get("import_job")}
553
 
554
+ # ── Upstream proxy endpoints ─────────────────────────────────────
555
+
556
+ @router.get("/api/proxy")
557
+ async def get_proxy(authorization: str | None = Header(default=None)):
558
+ require_auth_key(authorization)
559
+ return {"proxy": proxy_config.get_public()}
560
+
561
+ @router.post("/api/proxy")
562
+ async def update_proxy(body: ProxyUpdateRequest, authorization: str | None = Header(default=None)):
563
+ require_auth_key(authorization)
564
+
565
+ # If the client echoes back the masked form (contains `***@`), treat it as "no change"
566
+ # so the stored password is preserved.
567
+ url = body.url
568
+ if url is not None and "***@" in url:
569
+ url = None
570
+
571
+ try:
572
+ proxy_config.update(enabled=body.enabled, url=url)
573
+ except ValueError as exc:
574
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
575
+ return {"proxy": proxy_config.get_public()}
576
+
577
+ @router.post("/api/proxy/test")
578
+ async def test_proxy_endpoint(
579
+ body: ProxyTestRequest,
580
+ authorization: str | None = Header(default=None),
581
+ ):
582
+ require_auth_key(authorization)
583
+ candidate = (body.url or "").strip()
584
+ if not candidate or "***@" in candidate:
585
+ # Fall back to the stored url when caller omits it or sent the masked form.
586
+ candidate = (proxy_config.get().get("url") or "").strip()
587
+ if not candidate:
588
+ raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
589
+ result = await run_in_threadpool(test_proxy, candidate)
590
+ return {"result": result}
591
+
592
  app.include_router(router)
593
 
594
  @app.get("/{full_path:path}", include_in_schema=False)
services/image_service.py CHANGED
@@ -13,6 +13,7 @@ from curl_cffi.requests import Session
13
 
14
  from services.account_service import account_service
15
  from services import proof_of_work
 
16
 
17
 
18
  BASE_URL = "https://chatgpt.com"
@@ -100,6 +101,7 @@ def _new_session(access_token: str) -> tuple[Session, dict]:
100
  impersonate=fp.get("impersonate") or "edge101",
101
  verify=True,
102
  )
 
103
  session.headers.update(
104
  {
105
  "user-agent": fp.get("user-agent") or USER_AGENT,
 
13
 
14
  from services.account_service import account_service
15
  from services import proof_of_work
16
+ from services.proxy_service import apply_proxy_to_session
17
 
18
 
19
  BASE_URL = "https://chatgpt.com"
 
101
  impersonate=fp.get("impersonate") or "edge101",
102
  verify=True,
103
  )
104
+ apply_proxy_to_session(session)
105
  session.headers.update(
106
  {
107
  "user-agent": fp.get("user-agent") or USER_AGENT,
services/proxy_service.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upstream proxy configuration for requests against chatgpt.com.
2
+
3
+ Only chatgpt.com traffic goes through this proxy. Internal services like
4
+ sub2api / CPA pools always call their own base_url directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+ from threading import Lock
14
+ from urllib.parse import quote, urlparse, urlunparse
15
+
16
+ from curl_cffi.requests import Session
17
+
18
+ from services.config import DATA_DIR
19
+
20
+
21
+ PROXY_CONFIG_FILE = DATA_DIR / "proxy_config.json"
22
+
23
+
24
+ def _clean(value: object) -> str:
25
+ return str(value or "").strip()
26
+
27
+
28
+ def _is_valid_proxy_url(url: str) -> bool:
29
+ if not url:
30
+ return False
31
+ try:
32
+ parsed = urlparse(url)
33
+ except Exception:
34
+ return False
35
+ if parsed.scheme not in {"http", "https", "socks4", "socks5", "socks5h"}:
36
+ return False
37
+ return bool(parsed.hostname)
38
+
39
+
40
+ def _mask_proxy_url(url: str) -> str:
41
+ """Return a display-safe version of the proxy URL where password is hidden."""
42
+ if not url:
43
+ return ""
44
+ try:
45
+ parsed = urlparse(url)
46
+ except Exception:
47
+ return url
48
+ if not parsed.password:
49
+ return url
50
+ user = parsed.username or ""
51
+ host = parsed.hostname or ""
52
+ port = f":{parsed.port}" if parsed.port else ""
53
+ netloc = f"{quote(user, safe='')}:***@{host}{port}"
54
+ return urlunparse(parsed._replace(netloc=netloc))
55
+
56
+
57
+ class ProxyConfig:
58
+ def __init__(self, store_file: Path):
59
+ self._store_file = store_file
60
+ self._lock = Lock()
61
+ self._state = self._load()
62
+
63
+ def _default_state(self) -> dict:
64
+ env_url = _clean(os.getenv("CHATGPT2API_PROXY"))
65
+ if env_url and _is_valid_proxy_url(env_url):
66
+ return {"enabled": True, "url": env_url}
67
+ return {"enabled": False, "url": ""}
68
+
69
+ def _load(self) -> dict:
70
+ if not self._store_file.exists():
71
+ return self._default_state()
72
+ try:
73
+ raw = json.loads(self._store_file.read_text(encoding="utf-8"))
74
+ except Exception:
75
+ return self._default_state()
76
+ if not isinstance(raw, dict):
77
+ return self._default_state()
78
+ return {
79
+ "enabled": bool(raw.get("enabled")),
80
+ "url": _clean(raw.get("url")),
81
+ }
82
+
83
+ def _save(self) -> None:
84
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
85
+ self._store_file.write_text(
86
+ json.dumps(self._state, ensure_ascii=False, indent=2) + "\n",
87
+ encoding="utf-8",
88
+ )
89
+
90
+ def get(self) -> dict:
91
+ with self._lock:
92
+ return dict(self._state)
93
+
94
+ def get_public(self) -> dict:
95
+ """Return a masked representation safe to expose over the API."""
96
+ state = self.get()
97
+ return {
98
+ "enabled": bool(state.get("enabled")),
99
+ "url": _mask_proxy_url(_clean(state.get("url"))),
100
+ }
101
+
102
+ def update(self, *, enabled: bool | None, url: str | None) -> dict:
103
+ with self._lock:
104
+ next_state = dict(self._state)
105
+ if enabled is not None:
106
+ next_state["enabled"] = bool(enabled)
107
+ if url is not None:
108
+ new_url = _clean(url)
109
+ if new_url and not _is_valid_proxy_url(new_url):
110
+ raise ValueError("invalid proxy url")
111
+ next_state["url"] = new_url
112
+ if next_state.get("enabled") and not next_state.get("url"):
113
+ raise ValueError("proxy url is required when enabled")
114
+ self._state = next_state
115
+ self._save()
116
+ return dict(self._state)
117
+
118
+ def get_proxies(self) -> dict[str, str] | None:
119
+ """Return a dict suitable for curl_cffi's `proxies=` kwarg, or None."""
120
+ state = self.get()
121
+ if not state.get("enabled"):
122
+ return None
123
+ url = _clean(state.get("url"))
124
+ if not url:
125
+ return None
126
+ return {"http": url, "https": url}
127
+
128
+
129
+ proxy_config = ProxyConfig(PROXY_CONFIG_FILE)
130
+
131
+
132
+ def apply_proxy_to_session(session: Session) -> None:
133
+ """Attach the current upstream proxy to a curl_cffi Session, if configured."""
134
+ proxies = proxy_config.get_proxies()
135
+ if proxies:
136
+ session.proxies.update(proxies)
137
+
138
+
139
+ def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
140
+ """Probe chatgpt.com through the given proxy URL. Returns {ok, status, latency_ms, error}."""
141
+ candidate = _clean(url)
142
+ if not candidate:
143
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
144
+ if not _is_valid_proxy_url(candidate):
145
+ return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
146
+
147
+ session = Session(impersonate="edge101", verify=True)
148
+ session.proxies.update({"http": candidate, "https": candidate})
149
+ started = time.perf_counter()
150
+ try:
151
+ response = session.get(
152
+ "https://chatgpt.com/api/auth/csrf",
153
+ headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
154
+ timeout=timeout,
155
+ )
156
+ latency_ms = int((time.perf_counter() - started) * 1000)
157
+ return {
158
+ "ok": response.status_code < 500,
159
+ "status": int(response.status_code),
160
+ "latency_ms": latency_ms,
161
+ "error": None if response.status_code < 500 else f"HTTP {response.status_code}",
162
+ }
163
+ except Exception as exc:
164
+ latency_ms = int((time.perf_counter() - started) * 1000)
165
+ return {
166
+ "ok": False,
167
+ "status": 0,
168
+ "latency_ms": latency_ms,
169
+ "error": str(exc) or exc.__class__.__name__,
170
+ }
171
+ finally:
172
+ session.close()
web/src/app/settings/components/proxy-settings.tsx ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import {
5
+ Eye,
6
+ EyeOff,
7
+ LoaderCircle,
8
+ PlugZap,
9
+ Save,
10
+ Wifi,
11
+ } from "lucide-react";
12
+ import { toast } from "sonner";
13
+
14
+ import { Button } from "@/components/ui/button";
15
+ import { Card, CardContent } from "@/components/ui/card";
16
+ import { Input } from "@/components/ui/input";
17
+ import {
18
+ fetchProxy,
19
+ testProxy,
20
+ updateProxy,
21
+ type ProxySettings,
22
+ type ProxyTestResult,
23
+ } from "@/lib/api";
24
+
25
+ export function ProxySettingsCard() {
26
+ const didLoadRef = useRef(false);
27
+ const [settings, setSettings] = useState<ProxySettings>({ enabled: false, url: "" });
28
+ const [formUrl, setFormUrl] = useState("");
29
+ const [formEnabled, setFormEnabled] = useState(false);
30
+ const [showUrl, setShowUrl] = useState(false);
31
+ const [isLoading, setIsLoading] = useState(true);
32
+ const [isSaving, setIsSaving] = useState(false);
33
+ const [isTesting, setIsTesting] = useState(false);
34
+ const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
35
+
36
+ const load = async () => {
37
+ setIsLoading(true);
38
+ try {
39
+ const data = await fetchProxy();
40
+ setSettings(data.proxy);
41
+ setFormUrl(data.proxy.url);
42
+ setFormEnabled(data.proxy.enabled);
43
+ } catch (error) {
44
+ toast.error(error instanceof Error ? error.message : "加载代理配置失败");
45
+ } finally {
46
+ setIsLoading(false);
47
+ }
48
+ };
49
+
50
+ useEffect(() => {
51
+ if (didLoadRef.current) {
52
+ return;
53
+ }
54
+ didLoadRef.current = true;
55
+ void load();
56
+ }, []);
57
+
58
+ const urlChanged = formUrl.trim() !== settings.url;
59
+ const enabledChanged = formEnabled !== settings.enabled;
60
+ const dirty = urlChanged || enabledChanged;
61
+
62
+ const handleSave = async () => {
63
+ if (formEnabled && !formUrl.trim()) {
64
+ toast.error("启用代理时必须填写代理地址");
65
+ return;
66
+ }
67
+ setIsSaving(true);
68
+ try {
69
+ const payload: { enabled?: boolean; url?: string } = {};
70
+ if (enabledChanged) payload.enabled = formEnabled;
71
+ if (urlChanged) payload.url = formUrl.trim();
72
+ const data = await updateProxy(payload);
73
+ setSettings(data.proxy);
74
+ setFormUrl(data.proxy.url);
75
+ setFormEnabled(data.proxy.enabled);
76
+ toast.success("代理配置已保存");
77
+ } catch (error) {
78
+ toast.error(error instanceof Error ? error.message : "保存失败");
79
+ } finally {
80
+ setIsSaving(false);
81
+ }
82
+ };
83
+
84
+ const handleTest = async () => {
85
+ const candidate = formUrl.trim();
86
+ if (!candidate) {
87
+ toast.error("请先填写代理地址");
88
+ return;
89
+ }
90
+ setIsTesting(true);
91
+ setTestResult(null);
92
+ try {
93
+ const data = await testProxy(candidate);
94
+ setTestResult(data.result);
95
+ if (data.result.ok) {
96
+ toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
97
+ } else {
98
+ toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
99
+ }
100
+ } catch (error) {
101
+ toast.error(error instanceof Error ? error.message : "测试代理失败");
102
+ } finally {
103
+ setIsTesting(false);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
109
+ <CardContent className="space-y-6 p-6">
110
+ <div className="flex items-start justify-between">
111
+ <div className="flex items-center gap-3">
112
+ <div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
113
+ <Wifi className="size-5 text-stone-600" />
114
+ </div>
115
+ <div>
116
+ <h2 className="text-lg font-semibold tracking-tight">上游代理配置</h2>
117
+ <p className="text-sm text-stone-500">
118
+ 为 chatgpt.com 的请求配置出网代理,适合国内服务器部署;Sub2API / CPA 请求不受影响。
119
+ </p>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ {isLoading ? (
125
+ <div className="flex items-center justify-center py-10">
126
+ <LoaderCircle className="size-5 animate-spin text-stone-400" />
127
+ </div>
128
+ ) : (
129
+ <div className="space-y-4">
130
+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
131
+ <input
132
+ type="checkbox"
133
+ className="mt-1 size-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
134
+ checked={formEnabled}
135
+ onChange={(event) => setFormEnabled(event.target.checked)}
136
+ />
137
+ <div className="space-y-0.5">
138
+ <div className="text-sm font-medium text-stone-800">启用代理</div>
139
+ <div className="text-sm text-stone-500">
140
+ 关闭后 chatgpt.com 请求会直连。保存后立即生效,无需重启。
141
+ </div>
142
+ </div>
143
+ </label>
144
+
145
+ <div className="space-y-2">
146
+ <label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
147
+ <PlugZap className="size-3.5" />
148
+ 代理地址
149
+ </label>
150
+ <div className="relative">
151
+ <Input
152
+ type={showUrl ? "text" : "password"}
153
+ value={formUrl}
154
+ onChange={(event) => setFormUrl(event.target.value)}
155
+ placeholder="http://user:pass@host:port 或 socks5://host:port"
156
+ className="h-11 rounded-xl border-stone-200 bg-white pr-10 font-mono text-xs"
157
+ />
158
+ <button
159
+ type="button"
160
+ className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
161
+ onClick={() => setShowUrl((prev) => !prev)}
162
+ >
163
+ {showUrl ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
164
+ </button>
165
+ </div>
166
+ <div className="text-xs text-stone-400">
167
+ 支持 <code className="font-mono">http / https / socks4 / socks5 / socks5h</code>。
168
+ 密码部分展示时会被遮蔽(<code className="font-mono">***</code>),回传时保持原样即可保留密码。
169
+ </div>
170
+ </div>
171
+
172
+ {testResult ? (
173
+ <div
174
+ className={`rounded-xl border px-4 py-3 text-sm leading-6 ${
175
+ testResult.ok
176
+ ? "border-emerald-200 bg-emerald-50 text-emerald-800"
177
+ : "border-rose-200 bg-rose-50 text-rose-800"
178
+ }`}
179
+ >
180
+ {testResult.ok ? (
181
+ <>
182
+ 代理可用:HTTP {testResult.status},用时 {testResult.latency_ms} ms
183
+ </>
184
+ ) : (
185
+ <>代理不可用:{testResult.error ?? "未知错误"}(用时 {testResult.latency_ms} ms)</>
186
+ )}
187
+ </div>
188
+ ) : null}
189
+
190
+ <div className="flex items-center gap-2">
191
+ <Button
192
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
193
+ onClick={() => void handleSave()}
194
+ disabled={isSaving || !dirty}
195
+ >
196
+ {isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
197
+ 保存
198
+ </Button>
199
+ <Button
200
+ variant="outline"
201
+ className="h-10 rounded-xl border-stone-200 bg-white px-5 text-stone-700"
202
+ onClick={() => void handleTest()}
203
+ disabled={isTesting}
204
+ >
205
+ {isTesting ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
206
+ 测试连通
207
+ </Button>
208
+ </div>
209
+ </div>
210
+ )}
211
+ </CardContent>
212
+ </Card>
213
+ );
214
+ }
web/src/app/settings/page.tsx CHANGED
@@ -47,6 +47,7 @@ import {
47
  type CPAPool,
48
  type CPARemoteFile,
49
  } from "@/lib/api";
 
50
  import { Sub2APIConnections } from "@/app/settings/components/sub2api-connections";
51
 
52
  const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
@@ -305,6 +306,8 @@ export default function SettingsPage() {
305
  </section>
306
 
307
  <section className="space-y-6">
 
 
308
  <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
309
  <CardContent className="space-y-6 p-6">
310
  <div className="flex items-start justify-between">
 
47
  type CPAPool,
48
  type CPARemoteFile,
49
  } from "@/lib/api";
50
+ import { ProxySettingsCard } from "@/app/settings/components/proxy-settings";
51
  import { Sub2APIConnections } from "@/app/settings/components/sub2api-connections";
52
 
53
  const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
 
306
  </section>
307
 
308
  <section className="space-y-6">
309
+ <ProxySettingsCard />
310
+
311
  <Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
312
  <CardContent className="space-y-6 p-6">
313
  <div className="flex items-start justify-between">
web/src/lib/api.ts CHANGED
@@ -277,3 +277,35 @@ export async function startSub2APIImport(serverId: string, accountIds: string[])
277
  export async function fetchSub2APIImportJob(serverId: string) {
278
  return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`);
279
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  export async function fetchSub2APIImportJob(serverId: string) {
278
  return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`);
279
  }
280
+
281
+ // ── Upstream proxy ────────────────────────────────────────────────
282
+
283
+ export type ProxySettings = {
284
+ enabled: boolean;
285
+ url: string;
286
+ };
287
+
288
+ export type ProxyTestResult = {
289
+ ok: boolean;
290
+ status: number;
291
+ latency_ms: number;
292
+ error: string | null;
293
+ };
294
+
295
+ export async function fetchProxy() {
296
+ return httpRequest<{ proxy: ProxySettings }>("/api/proxy");
297
+ }
298
+
299
+ export async function updateProxy(updates: { enabled?: boolean; url?: string }) {
300
+ return httpRequest<{ proxy: ProxySettings }>("/api/proxy", {
301
+ method: "POST",
302
+ body: updates,
303
+ });
304
+ }
305
+
306
+ export async function testProxy(url?: string) {
307
+ return httpRequest<{ result: ProxyTestResult }>("/api/proxy/test", {
308
+ method: "POST",
309
+ body: { url: url ?? "" },
310
+ });
311
+ }