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

feat(sub2api): 支持按分组同步 OpenAI OAuth 账号

Browse files

- 连接配置新增 group_id 字段,同步时作为 group 参数传给 sub2api /admin/accounts
- 新增 list_remote_groups + GET /api/sub2api/servers/{id}/groups,按 platform=openai 过滤
- 编辑对话框支持「拉取分组」切换为下拉选择(含全部 / 未分组 / 具体分组选项)
- 连接列表副标题显示当前筛选分组

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

services/api.py CHANGED
@@ -17,6 +17,7 @@ from services.cpa_service import cpa_config, cpa_import_service, list_remote_fil
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,
21
  sub2api_import_service,
22
  )
@@ -98,6 +99,7 @@ class Sub2APIServerCreateRequest(BaseModel):
98
  email: str = ""
99
  password: str = ""
100
  api_key: str = ""
 
101
 
102
 
103
  class Sub2APIServerUpdateRequest(BaseModel):
@@ -106,6 +108,7 @@ class Sub2APIServerUpdateRequest(BaseModel):
106
  email: str | None = None
107
  password: str | None = None
108
  api_key: str | None = None
 
109
 
110
 
111
  class Sub2APIImportRequest(BaseModel):
@@ -478,6 +481,7 @@ def create_app() -> FastAPI:
478
  email=body.email,
479
  password=body.password,
480
  api_key=body.api_key,
 
481
  )
482
  return {
483
  "server": sanitize_sub2api_server(server),
@@ -509,6 +513,21 @@ def create_app() -> FastAPI:
509
  raise HTTPException(status_code=404, detail={"error": "server not found"})
510
  return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  @router.get("/api/sub2api/servers/{server_id}/accounts")
513
  async def sub2api_server_accounts(
514
  server_id: str,
 
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
+ list_remote_groups as sub2api_list_remote_groups,
21
  sub2api_config,
22
  sub2api_import_service,
23
  )
 
99
  email: str = ""
100
  password: str = ""
101
  api_key: str = ""
102
+ group_id: str = ""
103
 
104
 
105
  class Sub2APIServerUpdateRequest(BaseModel):
 
108
  email: str | None = None
109
  password: str | None = None
110
  api_key: str | None = None
111
+ group_id: str | None = None
112
 
113
 
114
  class Sub2APIImportRequest(BaseModel):
 
481
  email=body.email,
482
  password=body.password,
483
  api_key=body.api_key,
484
+ group_id=body.group_id,
485
  )
486
  return {
487
  "server": sanitize_sub2api_server(server),
 
513
  raise HTTPException(status_code=404, detail={"error": "server not found"})
514
  return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
515
 
516
+ @router.get("/api/sub2api/servers/{server_id}/groups")
517
+ async def sub2api_server_groups(
518
+ server_id: str,
519
+ authorization: str | None = Header(default=None),
520
+ ):
521
+ require_auth_key(authorization)
522
+ server = sub2api_config.get_server(server_id)
523
+ if server is None:
524
+ raise HTTPException(status_code=404, detail={"error": "server not found"})
525
+ try:
526
+ groups = await run_in_threadpool(sub2api_list_remote_groups, server)
527
+ except Exception as exc:
528
+ raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
529
+ return {"server_id": server_id, "groups": groups}
530
+
531
  @router.get("/api/sub2api/servers/{server_id}/accounts")
532
  async def sub2api_server_accounts(
533
  server_id: str,
services/sub2api_service.py CHANGED
@@ -65,6 +65,7 @@ def _normalize_server(raw: dict) -> dict:
65
  "email": _clean(raw.get("email")),
66
  "password": _clean(raw.get("password")),
67
  "api_key": _clean(raw.get("api_key")),
 
68
  "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
69
  }
70
 
@@ -104,7 +105,16 @@ class Sub2APIConfig:
104
  return dict(server)
105
  return None
106
 
107
- def add_server(self, *, name: str, base_url: str, email: str, password: str, api_key: str) -> dict:
 
 
 
 
 
 
 
 
 
108
  server = _normalize_server({
109
  "id": _new_id(),
110
  "name": name,
@@ -112,6 +122,7 @@ class Sub2APIConfig:
112
  "email": email,
113
  "password": password,
114
  "api_key": api_key,
 
115
  })
116
  with self._lock:
117
  self._servers.append(server)
@@ -240,21 +251,25 @@ def list_remote_accounts(server: dict) -> list[dict]:
240
  return []
241
 
242
  headers = _auth_headers(server)
 
243
 
244
  session = Session(verify=True)
245
  items: list[dict] = []
246
  try:
247
  page = 1
248
  while True:
 
 
 
 
 
 
 
 
249
  response = session.get(
250
  f"{base_url.rstrip('/')}/api/v1/admin/accounts",
251
  headers=headers,
252
- params={
253
- "platform": "openai",
254
- "type": "oauth",
255
- "page": page,
256
- "page_size": 200,
257
- },
258
  timeout=30,
259
  )
260
  if not response.ok:
@@ -293,6 +308,62 @@ def list_remote_accounts(server: dict) -> list[dict]:
293
  return items
294
 
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def _fetch_access_token_for_account(server: dict, account_id: str) -> tuple[str, dict]:
297
  """Return (access_token, account_meta) for a single sub2api account id."""
298
  base_url = _clean(server.get("base_url"))
 
65
  "email": _clean(raw.get("email")),
66
  "password": _clean(raw.get("password")),
67
  "api_key": _clean(raw.get("api_key")),
68
+ "group_id": _clean(raw.get("group_id")),
69
  "import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
70
  }
71
 
 
105
  return dict(server)
106
  return None
107
 
108
+ def add_server(
109
+ self,
110
+ *,
111
+ name: str,
112
+ base_url: str,
113
+ email: str,
114
+ password: str,
115
+ api_key: str,
116
+ group_id: str = "",
117
+ ) -> dict:
118
  server = _normalize_server({
119
  "id": _new_id(),
120
  "name": name,
 
122
  "email": email,
123
  "password": password,
124
  "api_key": api_key,
125
+ "group_id": group_id,
126
  })
127
  with self._lock:
128
  self._servers.append(server)
 
251
  return []
252
 
253
  headers = _auth_headers(server)
254
+ group_id = _clean(server.get("group_id"))
255
 
256
  session = Session(verify=True)
257
  items: list[dict] = []
258
  try:
259
  page = 1
260
  while True:
261
+ params: dict[str, object] = {
262
+ "platform": "openai",
263
+ "type": "oauth",
264
+ "page": page,
265
+ "page_size": 200,
266
+ }
267
+ if group_id:
268
+ params["group"] = group_id
269
  response = session.get(
270
  f"{base_url.rstrip('/')}/api/v1/admin/accounts",
271
  headers=headers,
272
+ params=params,
 
 
 
 
 
273
  timeout=30,
274
  )
275
  if not response.ok:
 
308
  return items
309
 
310
 
311
+ def list_remote_groups(server: dict) -> list[dict]:
312
+ """Return OpenAI account groups from a sub2api server."""
313
+ base_url = _clean(server.get("base_url"))
314
+ if not base_url:
315
+ return []
316
+
317
+ headers = _auth_headers(server)
318
+
319
+ session = Session(verify=True)
320
+ items: list[dict] = []
321
+ try:
322
+ page = 1
323
+ while True:
324
+ response = session.get(
325
+ f"{base_url.rstrip('/')}/api/v1/admin/groups",
326
+ headers=headers,
327
+ params={
328
+ "platform": "openai",
329
+ "page": page,
330
+ "page_size": 200,
331
+ },
332
+ timeout=30,
333
+ )
334
+ if not response.ok:
335
+ raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}")
336
+ payload = response.json()
337
+
338
+ data = payload.get("data") if isinstance(payload, dict) else None
339
+ if not isinstance(data, list) or not data:
340
+ break
341
+
342
+ for group in data:
343
+ if not isinstance(group, dict):
344
+ continue
345
+ group_id = group.get("id")
346
+ if group_id is None:
347
+ continue
348
+ items.append({
349
+ "id": str(group_id),
350
+ "name": _clean(group.get("name")),
351
+ "description": _clean(group.get("description")),
352
+ "status": _clean(group.get("status")),
353
+ "account_count": int(group.get("account_count") or 0),
354
+ "active_account_count": int(group.get("active_account_count") or 0),
355
+ })
356
+
357
+ total = int(payload.get("total") or 0) if isinstance(payload, dict) else 0
358
+ if page * 200 >= total or len(data) < 200:
359
+ break
360
+ page += 1
361
+ finally:
362
+ session.close()
363
+
364
+ return items
365
+
366
+
367
  def _fetch_access_token_for_account(server: dict, account_id: str) -> tuple[str, dict]:
368
  """Return (access_token, account_meta) for a single sub2api account id."""
369
  base_url = _clean(server.get("base_url"))
web/src/app/settings/components/sub2api-connections.tsx CHANGED
@@ -5,11 +5,13 @@ import {
5
  Eye,
6
  EyeOff,
7
  Import,
 
8
  Link2,
9
  LoaderCircle,
10
  Mail,
11
  Pencil,
12
  Plus,
 
13
  Save,
14
  Search,
15
  ServerCog,
@@ -42,10 +44,12 @@ import {
42
  createSub2APIServer,
43
  deleteSub2APIServer,
44
  fetchSub2APIServerAccounts,
 
45
  fetchSub2APIServers,
46
  startSub2APIImport,
47
  updateSub2APIServer,
48
  type Sub2APIRemoteAccount,
 
49
  type Sub2APIServer,
50
  } from "@/lib/api";
51
 
@@ -89,10 +93,14 @@ export function Sub2APIConnections() {
89
  const [formEmail, setFormEmail] = useState("");
90
  const [formPassword, setFormPassword] = useState("");
91
  const [formApiKey, setFormApiKey] = useState("");
 
92
  const [authMode, setAuthMode] = useState<AuthMode>("password");
93
  const [showSecret, setShowSecret] = useState(false);
94
  const [isSaving, setIsSaving] = useState(false);
95
 
 
 
 
96
  const [deletingId, setDeletingId] = useState<string | null>(null);
97
  const [loadingAccountsId, setLoadingAccountsId] = useState<string | null>(null);
98
 
@@ -166,8 +174,10 @@ export function Sub2APIConnections() {
166
  setFormEmail("");
167
  setFormPassword("");
168
  setFormApiKey("");
 
169
  setAuthMode("password");
170
  setShowSecret(false);
 
171
  setDialogOpen(true);
172
  };
173
 
@@ -178,11 +188,34 @@ export function Sub2APIConnections() {
178
  setFormEmail(server.email);
179
  setFormPassword("");
180
  setFormApiKey("");
 
181
  setAuthMode(server.has_api_key ? "api_key" : "password");
182
  setShowSecret(false);
 
183
  setDialogOpen(true);
184
  };
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  const handleSave = async () => {
187
  if (!formBaseUrl.trim()) {
188
  toast.error("请输入 Sub2API 地址");
@@ -208,6 +241,7 @@ export function Sub2APIConnections() {
208
  const updates: Parameters<typeof updateSub2APIServer>[1] = {
209
  name: formName.trim(),
210
  base_url: formBaseUrl.trim(),
 
211
  };
212
  if (authMode === "password") {
213
  updates.email = formEmail.trim();
@@ -232,6 +266,7 @@ export function Sub2APIConnections() {
232
  email: authMode === "password" ? formEmail.trim() : "",
233
  password: authMode === "password" ? formPassword.trim() : "",
234
  api_key: authMode === "api_key" ? formApiKey.trim() : "",
 
235
  });
236
  setServers(data.servers);
237
  toast.success("连接已添加");
@@ -401,6 +436,7 @@ export function Sub2APIConnections() {
401
  <div className="truncate text-xs text-stone-400">
402
  {server.base_url}
403
  {server.email ? ` · ${server.email}` : server.has_api_key ? " · API Key" : ""}
 
404
  </div>
405
  </div>
406
  <div className="flex items-center gap-1">
@@ -615,6 +651,60 @@ export function Sub2APIConnections() {
615
  </div>
616
  </div>
617
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  </div>
619
  <DialogFooter className="pt-2">
620
  <Button
 
5
  Eye,
6
  EyeOff,
7
  Import,
8
+ Layers,
9
  Link2,
10
  LoaderCircle,
11
  Mail,
12
  Pencil,
13
  Plus,
14
+ RefreshCcw,
15
  Save,
16
  Search,
17
  ServerCog,
 
44
  createSub2APIServer,
45
  deleteSub2APIServer,
46
  fetchSub2APIServerAccounts,
47
+ fetchSub2APIServerGroups,
48
  fetchSub2APIServers,
49
  startSub2APIImport,
50
  updateSub2APIServer,
51
  type Sub2APIRemoteAccount,
52
+ type Sub2APIRemoteGroup,
53
  type Sub2APIServer,
54
  } from "@/lib/api";
55
 
 
93
  const [formEmail, setFormEmail] = useState("");
94
  const [formPassword, setFormPassword] = useState("");
95
  const [formApiKey, setFormApiKey] = useState("");
96
+ const [formGroupId, setFormGroupId] = useState("");
97
  const [authMode, setAuthMode] = useState<AuthMode>("password");
98
  const [showSecret, setShowSecret] = useState(false);
99
  const [isSaving, setIsSaving] = useState(false);
100
 
101
+ const [remoteGroups, setRemoteGroups] = useState<Sub2APIRemoteGroup[] | null>(null);
102
+ const [isLoadingGroups, setIsLoadingGroups] = useState(false);
103
+
104
  const [deletingId, setDeletingId] = useState<string | null>(null);
105
  const [loadingAccountsId, setLoadingAccountsId] = useState<string | null>(null);
106
 
 
174
  setFormEmail("");
175
  setFormPassword("");
176
  setFormApiKey("");
177
+ setFormGroupId("");
178
  setAuthMode("password");
179
  setShowSecret(false);
180
+ setRemoteGroups(null);
181
  setDialogOpen(true);
182
  };
183
 
 
188
  setFormEmail(server.email);
189
  setFormPassword("");
190
  setFormApiKey("");
191
+ setFormGroupId(server.group_id || "");
192
  setAuthMode(server.has_api_key ? "api_key" : "password");
193
  setShowSecret(false);
194
+ setRemoteGroups(null);
195
  setDialogOpen(true);
196
  };
197
 
198
+ const handleFetchGroups = async () => {
199
+ if (!editingServer) {
200
+ toast.error("请先保存连接后再拉取分组");
201
+ return;
202
+ }
203
+ setIsLoadingGroups(true);
204
+ try {
205
+ const data = await fetchSub2APIServerGroups(editingServer.id);
206
+ setRemoteGroups(data.groups);
207
+ if (data.groups.length === 0) {
208
+ toast.message("远端没有 platform=openai 的分组");
209
+ } else {
210
+ toast.success(`读取到 ${data.groups.length} 个分组`);
211
+ }
212
+ } catch (error) {
213
+ toast.error(error instanceof Error ? error.message : "拉取分组失败");
214
+ } finally {
215
+ setIsLoadingGroups(false);
216
+ }
217
+ };
218
+
219
  const handleSave = async () => {
220
  if (!formBaseUrl.trim()) {
221
  toast.error("请输入 Sub2API 地址");
 
241
  const updates: Parameters<typeof updateSub2APIServer>[1] = {
242
  name: formName.trim(),
243
  base_url: formBaseUrl.trim(),
244
+ group_id: formGroupId.trim(),
245
  };
246
  if (authMode === "password") {
247
  updates.email = formEmail.trim();
 
266
  email: authMode === "password" ? formEmail.trim() : "",
267
  password: authMode === "password" ? formPassword.trim() : "",
268
  api_key: authMode === "api_key" ? formApiKey.trim() : "",
269
+ group_id: formGroupId.trim(),
270
  });
271
  setServers(data.servers);
272
  toast.success("连接已添加");
 
436
  <div className="truncate text-xs text-stone-400">
437
  {server.base_url}
438
  {server.email ? ` · ${server.email}` : server.has_api_key ? " · API Key" : ""}
439
+ {server.group_id ? ` · 分组 ${server.group_id}` : " · 全部分组"}
440
  </div>
441
  </div>
442
  <div className="flex items-center gap-1">
 
651
  </div>
652
  </div>
653
  )}
654
+ <div className="space-y-2">
655
+ <label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
656
+ <Layers className="size-3.5" />
657
+ 分组(可选)
658
+ </label>
659
+ {remoteGroups && remoteGroups.length > 0 ? (
660
+ <Select value={formGroupId || "__all__"} onValueChange={(value) => setFormGroupId(value === "__all__" ? "" : value)}>
661
+ <SelectTrigger className="h-11 rounded-xl border-stone-200 bg-white">
662
+ <SelectValue placeholder="选择分组" />
663
+ </SelectTrigger>
664
+ <SelectContent>
665
+ <SelectItem value="__all__">全部分组(不限制)</SelectItem>
666
+ <SelectItem value="ungrouped">未分组</SelectItem>
667
+ {remoteGroups.map((group) => (
668
+ <SelectItem key={group.id} value={group.id}>
669
+ {group.name || `Group ${group.id}`}
670
+ {group.account_count
671
+ ? `��${group.active_account_count}/${group.account_count})`
672
+ : ""}
673
+ </SelectItem>
674
+ ))}
675
+ </SelectContent>
676
+ </Select>
677
+ ) : (
678
+ <Input
679
+ value={formGroupId}
680
+ onChange={(event) => setFormGroupId(event.target.value)}
681
+ placeholder="留空则同步所有分组;或填写分组 ID / ungrouped"
682
+ className="h-11 rounded-xl border-stone-200 bg-white"
683
+ />
684
+ )}
685
+ {editingServer ? (
686
+ <div className="flex items-center justify-between gap-2 text-xs text-stone-500">
687
+ <span>同步时会用分组 ID 过滤,留空 = 同步所有 OpenAI OAuth 账号。</span>
688
+ <Button
689
+ variant="outline"
690
+ className="h-8 rounded-lg border-stone-200 bg-white px-2 text-xs text-stone-600"
691
+ onClick={() => void handleFetchGroups()}
692
+ disabled={isLoadingGroups}
693
+ >
694
+ {isLoadingGroups ? (
695
+ <LoaderCircle className="size-3.5 animate-spin" />
696
+ ) : (
697
+ <RefreshCcw className="size-3.5" />
698
+ )}
699
+ {remoteGroups ? "重新拉取" : "拉取分组"}
700
+ </Button>
701
+ </div>
702
+ ) : (
703
+ <div className="text-xs text-stone-500">
704
+ 添加完连接后可在编辑对话框里点「拉取分组」选择具体分组。
705
+ </div>
706
+ )}
707
+ </div>
708
  </div>
709
  <DialogFooter className="pt-2">
710
  <Button
web/src/lib/api.ts CHANGED
@@ -215,6 +215,7 @@ export type Sub2APIServer = {
215
  base_url: string;
216
  email: string;
217
  has_api_key: boolean;
 
218
  import_job?: CPAImportJob | null;
219
  };
220
 
@@ -228,6 +229,15 @@ export type Sub2APIRemoteAccount = {
228
  has_refresh_token: boolean;
229
  };
230
 
 
 
 
 
 
 
 
 
 
231
  export async function fetchSub2APIServers() {
232
  return httpRequest<{ servers: Sub2APIServer[] }>("/api/sub2api/servers");
233
  }
@@ -238,6 +248,7 @@ export async function createSub2APIServer(server: {
238
  email: string;
239
  password: string;
240
  api_key: string;
 
241
  }) {
242
  return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>("/api/sub2api/servers", {
243
  method: "POST",
@@ -247,7 +258,14 @@ export async function createSub2APIServer(server: {
247
 
248
  export async function updateSub2APIServer(
249
  serverId: string,
250
- updates: { name?: string; base_url?: string; email?: string; password?: string; api_key?: string },
 
 
 
 
 
 
 
251
  ) {
252
  return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
253
  method: "POST",
@@ -255,6 +273,12 @@ export async function updateSub2APIServer(
255
  });
256
  }
257
 
 
 
 
 
 
 
258
  export async function deleteSub2APIServer(serverId: string) {
259
  return httpRequest<{ servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
260
  method: "DELETE",
 
215
  base_url: string;
216
  email: string;
217
  has_api_key: boolean;
218
+ group_id: string;
219
  import_job?: CPAImportJob | null;
220
  };
221
 
 
229
  has_refresh_token: boolean;
230
  };
231
 
232
+ export type Sub2APIRemoteGroup = {
233
+ id: string;
234
+ name: string;
235
+ description: string;
236
+ status: string;
237
+ account_count: number;
238
+ active_account_count: number;
239
+ };
240
+
241
  export async function fetchSub2APIServers() {
242
  return httpRequest<{ servers: Sub2APIServer[] }>("/api/sub2api/servers");
243
  }
 
248
  email: string;
249
  password: string;
250
  api_key: string;
251
+ group_id: string;
252
  }) {
253
  return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>("/api/sub2api/servers", {
254
  method: "POST",
 
258
 
259
  export async function updateSub2APIServer(
260
  serverId: string,
261
+ updates: {
262
+ name?: string;
263
+ base_url?: string;
264
+ email?: string;
265
+ password?: string;
266
+ api_key?: string;
267
+ group_id?: string;
268
+ },
269
  ) {
270
  return httpRequest<{ server: Sub2APIServer; servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
271
  method: "POST",
 
273
  });
274
  }
275
 
276
+ export async function fetchSub2APIServerGroups(serverId: string) {
277
+ return httpRequest<{ server_id: string; groups: Sub2APIRemoteGroup[] }>(
278
+ `/api/sub2api/servers/${serverId}/groups`,
279
+ );
280
+ }
281
+
282
  export async function deleteSub2APIServer(serverId: string) {
283
  return httpRequest<{ servers: Sub2APIServer[] }>(`/api/sub2api/servers/${serverId}`, {
284
  method: "DELETE",