Переглянути джерело

Make SUB2API proxy defaults available across panel flows

Expose a configurable default proxy for SUB2API, resolve the proxy once,
persist the proxy_id in state, and reuse it across auth URL generation,
code exchange, and account creation so one OAuth flow stays on one proxy.

Constraint: Upstream GitHub permission is READ, so publication must use a fork PR
Rejected: Push to origin | current GitHub viewer permission does not allow upstream branch publication
Rejected: Resolve proxy independently in later steps | could drift from the proxy chosen at auth URL generation
Confidence: high
Scope-risk: moderate
Reversibility: clean
Tested: npm test passed 183/183
Not-tested: Manual browser extension flow against a live SUB2API deployment
lizhelang 4 місяців тому
батько
коміт
81613b865a

+ 2 - 1
.gitignore

@@ -6,4 +6,5 @@
 
 /data/account-run-history.txt
 /data/account-run-history.json
-.npm-test.log
+.npm-test.log
+.omx/

+ 11 - 0
README.md

@@ -150,6 +150,17 @@ http(s)://<your-host>/management.html#/oauth
 
 Step 1 和 Step 10 都依赖这个地址。
 
+### `SUB2API`
+
+当 `来源 = SUB2API` 时,需要配置:
+
+- `SUB2API`:后台账号管理页地址
+- `账号 / 密码`:SUB2API 管理员登录信息
+- `分组`:目标 OpenAI 分组,留空时默认 `codex`
+- `默认代理`:创建账号时必须绑定的代理名称或代理 ID,默认 `shadowrocket`
+
+插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并分别把 `proxy_id` 传给 OAuth 链接生成、授权码交换和账号创建请求。如果名称匹配到多个代理,请改填代理 ID。
+
 ### `Mail`
 
 支持五种验证码来源:

+ 5 - 0
background.js

@@ -141,6 +141,7 @@ const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
 const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
 const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts';
 const DEFAULT_SUB2API_GROUP_NAME = 'codex';
+const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
 const DEFAULT_SUB2API_REDIRECT_URI = 'http://localhost:1455/auth/callback';
 const AUTO_RUN_TIMER_ALARM_NAME = 'auto-run-timer';
 const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
@@ -215,6 +216,7 @@ const PERSISTED_SETTING_DEFAULTS = {
   sub2apiEmail: '',
   sub2apiPassword: '',
   sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
+  sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
   customPassword: '',
   autoRunSkipFailures: false,
   autoRunFallbackThreadIntervalMinutes: 0,
@@ -287,6 +289,7 @@ const DEFAULT_STATE = {
   sub2apiOAuthState: null, // SUB2API OpenAI Auth state。
   sub2apiGroupId: null, // SUB2API 目标分组 ID。
   sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。
+  sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。
   flowStartTime: null, // 当前流程开始时间。
   tabRegistry: {}, // 程序维护的标签页注册表。
   sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。
@@ -828,6 +831,8 @@ function normalizePersistentSettingValue(key, value) {
       return String(value || '');
     case 'sub2apiGroupName':
       return String(value || '').trim();
+    case 'sub2apiDefaultProxyName':
+      return String(value || '').trim() || DEFAULT_SUB2API_PROXY_NAME;
     case 'customPassword':
       return String(value || '');
     case 'autoRunSkipFailures':

+ 1 - 0
background/message-router.js

@@ -106,6 +106,7 @@
           if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
           if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
           if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
+          if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
           if (Object.keys(updates).length) {
             await setState(updates);
           }

+ 1 - 0
background/panel-bridge.js

@@ -120,6 +120,7 @@
           sub2apiEmail: state.sub2apiEmail,
           sub2apiPassword: state.sub2apiPassword,
           sub2apiGroupName: groupName,
+          sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
           logStep: 6,
         },
       }, {

+ 2 - 0
background/steps/platform-verify.js

@@ -142,6 +142,8 @@
           sub2apiEmail: state.sub2apiEmail,
           sub2apiPassword: state.sub2apiPassword,
           sub2apiGroupName: state.sub2apiGroupName,
+          sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
+          sub2apiProxyId: state.sub2apiProxyId,
           sub2apiSessionId: state.sub2apiSessionId,
           sub2apiOAuthState: state.sub2apiOAuthState,
           sub2apiGroupId: state.sub2apiGroupId,

+ 157 - 0
content/sub2api-panel.js

@@ -4,6 +4,7 @@ console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href)
 
 const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener';
 const SUB2API_DEFAULT_GROUP_NAME = 'codex';
+const SUB2API_DEFAULT_PROXY_NAME = 'shadowrocket';
 const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
 const SUB2API_DEFAULT_CONCURRENCY = 10;
 const SUB2API_DEFAULT_PRIORITY = 1;
@@ -188,6 +189,149 @@ async function getGroupByName(origin, token, groupName) {
   return group;
 }
 
+function normalizeSub2ApiProxyPreference(value) {
+  return String(value || '').trim();
+}
+
+function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) {
+  if (payload.sub2apiDefaultProxyName !== undefined) {
+    return normalizeSub2ApiProxyPreference(payload.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
+  }
+  if (backgroundState.sub2apiDefaultProxyName !== undefined) {
+    return normalizeSub2ApiProxyPreference(backgroundState.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
+  }
+  return SUB2API_DEFAULT_PROXY_NAME;
+}
+
+function normalizeProxyId(value) {
+  if (value === undefined || value === null || value === '') {
+    return null;
+  }
+  const normalized = Number(value);
+  if (!Number.isSafeInteger(normalized) || normalized <= 0) {
+    return null;
+  }
+  return normalized;
+}
+
+function buildProxyDisplayName(proxy = {}) {
+  const id = normalizeProxyId(proxy.id);
+  const name = String(proxy.name || '').trim();
+  const protocol = String(proxy.protocol || '').trim();
+  const host = String(proxy.host || '').trim();
+  const port = proxy.port === undefined || proxy.port === null ? '' : String(proxy.port).trim();
+  const address = protocol && host && port ? `${protocol}://${host}:${port}` : '';
+  const parts = [
+    name || '(未命名代理)',
+    id ? `#${id}` : '',
+    address,
+  ].filter(Boolean);
+  return parts.join(' ');
+}
+
+function buildProxySearchText(proxy = {}) {
+  return [
+    proxy.id,
+    proxy.name,
+    proxy.protocol,
+    proxy.host,
+    proxy.port,
+    buildProxyDisplayName(proxy),
+  ]
+    .filter((value) => value !== undefined && value !== null && value !== '')
+    .map((value) => String(value).trim().toLowerCase())
+    .filter(Boolean)
+    .join(' ');
+}
+
+function isActiveProxy(proxy = {}) {
+  const status = String(proxy.status || '').trim().toLowerCase();
+  return !status || status === 'active';
+}
+
+function findSub2ApiProxy(proxies = [], preference = '') {
+  const activeProxies = (Array.isArray(proxies) ? proxies : [])
+    .filter(isActiveProxy)
+    .filter((proxy) => normalizeProxyId(proxy.id));
+  const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase();
+  const preferredId = normalizeProxyId(normalizedPreference);
+
+  if (preferredId) {
+    const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId);
+    return {
+      proxy: matchedById || null,
+      reason: matchedById ? 'id' : 'missing-id',
+      candidates: activeProxies,
+    };
+  }
+
+  if (normalizedPreference) {
+    const exactMatches = activeProxies.filter((proxy) => {
+      const name = String(proxy.name || '').trim().toLowerCase();
+      return name === normalizedPreference;
+    });
+    if (exactMatches.length === 1) {
+      return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies };
+    }
+    if (exactMatches.length > 1) {
+      return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches };
+    }
+
+    const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference));
+    if (fuzzyMatches.length === 1) {
+      return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies };
+    }
+    if (fuzzyMatches.length > 1) {
+      return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches };
+    }
+
+    return { proxy: null, reason: 'missing-name', candidates: activeProxies };
+  }
+
+  if (activeProxies.length === 1) {
+    return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies };
+  }
+  return {
+    proxy: null,
+    reason: activeProxies.length ? 'no-preference' : 'none-active',
+    candidates: activeProxies,
+  };
+}
+
+async function resolveSub2ApiProxy(origin, token, preference = '') {
+  const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', {
+    method: 'GET',
+    token,
+  });
+  if (!Array.isArray(proxies)) {
+    throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。');
+  }
+
+  const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference);
+  if (proxy) {
+    return proxy;
+  }
+
+  const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)';
+  const available = (candidates || [])
+    .slice(0, 8)
+    .map(buildProxyDisplayName)
+    .join(',') || '无可用代理';
+  if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') {
+    throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`);
+  }
+  if (reason === 'missing-id') {
+    throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`);
+  }
+  if (reason === 'missing-name') {
+    throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`);
+  }
+  if (reason === 'no-preference') {
+    throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID。可用代理:${available}`);
+  }
+  throw new Error('SUB2API 没有可用代理;当前流程要求账号必须绑定代理。');
+}
+
 function buildDraftAccountName(groupName) {
   const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME)
     .trim()
@@ -306,9 +450,13 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
 
   const { origin, token } = await loginSub2Api(payload);
   const group = await getGroupByName(origin, token, groupName);
+  const proxyPreference = resolveSub2ApiProxyPreference(payload);
+  const proxy = await resolveSub2ApiProxy(origin, token, proxyPreference);
+  const proxyId = normalizeProxyId(proxy.id);
   const draftName = buildDraftAccountName(group.name || groupName);
 
   log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`);
+  log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
   log(`步骤 ${logStep}:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`);
 
   const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
@@ -316,6 +464,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
     token,
     body: {
       redirect_uri: redirectUri,
+      proxy_id: proxyId,
     },
   });
 
@@ -334,6 +483,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
     sub2apiOAuthState: oauthState,
     sub2apiGroupId: group.id,
     sub2apiDraftName: draftName,
+    sub2apiProxyId: proxyId,
   };
   if (report) {
     reportComplete(1, result);
@@ -354,6 +504,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
     || buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
 
   const { origin, token } = await loginSub2Api(payload);
+  const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState);
+  const preferredProxyId = normalizeProxyId(payload.sub2apiProxyId || backgroundState.sub2apiProxyId);
+  const proxy = await resolveSub2ApiProxy(origin, token, preferredProxyId || proxyPreference);
+  const proxyId = normalizeProxyId(proxy.id);
   const group = payload.sub2apiGroupId
     ? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
     : await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
@@ -366,6 +520,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
   }
 
   log('步骤 10:正在向 SUB2API 交换 OpenAI 授权码...');
+  log(`步骤 10:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
   const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
     method: 'POST',
     token,
@@ -373,6 +528,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
       session_id: sessionId,
       code: callback.code,
       state: callback.state,
+      proxy_id: proxyId,
     },
   });
 
@@ -391,6 +547,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
     concurrency: SUB2API_DEFAULT_CONCURRENCY,
     priority: SUB2API_DEFAULT_PRIORITY,
     rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
+    proxy_id: proxyId,
     group_ids: [groupId],
     auto_pause_on_expired: true,
   };

+ 5 - 0
sidepanel/sidepanel.html

@@ -140,6 +140,11 @@
         <span class="data-label">分组</span>
         <input type="text" id="input-sub2api-group" class="data-input" placeholder="默认 codex" />
       </div>
+      <div class="data-row" id="row-sub2api-default-proxy" style="display:none;">
+        <span class="data-label">默认代理</span>
+        <input type="text" id="input-sub2api-default-proxy" class="data-input"
+          placeholder="shadowrocket 或代理 ID" />
+      </div>
       <div class="data-row">
         <span class="data-label">账户密码</span>
         <div class="input-with-icon">

+ 15 - 0
sidepanel/sidepanel.js

@@ -74,6 +74,8 @@ const rowSub2ApiPassword = document.getElementById('row-sub2api-password');
 const inputSub2ApiPassword = document.getElementById('input-sub2api-password');
 const rowSub2ApiGroup = document.getElementById('row-sub2api-group');
 const inputSub2ApiGroup = document.getElementById('input-sub2api-group');
+const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
+const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
 const selectMailProvider = document.getElementById('select-mail-provider');
 const btnMailLogin = document.getElementById('btn-mail-login');
 const rowMail2925Mode = document.getElementById('row-mail-2925-mode');
@@ -202,6 +204,8 @@ const VERIFICATION_RESEND_COUNT_MIN = 0;
 const VERIFICATION_RESEND_COUNT_MAX = 20;
 const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
 const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
+const DEFAULT_CPA_CALLBACK_MODE = 'step8';
+const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
 const MAIL_2925_MODE_PROVIDE = 'provide';
 const MAIL_2925_MODE_RECEIVE = 'receive';
 const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
@@ -1306,6 +1310,7 @@ function collectSettingsPayload() {
     sub2apiEmail: inputSub2ApiEmail.value.trim(),
     sub2apiPassword: inputSub2ApiPassword.value,
     sub2apiGroupName: inputSub2ApiGroup.value.trim(),
+    sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim() || DEFAULT_SUB2API_PROXY_NAME,
     customPassword: inputPassword.value,
     mailProvider: selectMailProvider.value,
     mail2925Mode: getSelectedMail2925Mode(),
@@ -1676,6 +1681,7 @@ function applySettingsState(state) {
   inputSub2ApiEmail.value = state?.sub2apiEmail || '';
   inputSub2ApiPassword.value = state?.sub2apiPassword || '';
   inputSub2ApiGroup.value = state?.sub2apiGroupName || '';
+  inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || DEFAULT_SUB2API_PROXY_NAME;
   const restoredMailProvider = isCustomMailProvider(state?.mailProvider)
     || [ICLOUD_PROVIDER, 'hotmail-api', GMAIL_PROVIDER, 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim())
     ? String(state?.mailProvider || '163').trim()
@@ -2470,6 +2476,7 @@ function updatePanelModeUI() {
   rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
   rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none';
+  rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none';
 
   const step9Btn = document.querySelector('.step-btn[data-step-key="platform-verify"]');
   if (step9Btn) {
@@ -3701,6 +3708,14 @@ inputSub2ApiGroup.addEventListener('blur', () => {
   saveSettings({ silent: true }).catch(() => { });
 });
 
+inputSub2ApiDefaultProxy.addEventListener('input', () => {
+  markSettingsDirty(true);
+  scheduleSettingsAutoSave();
+});
+inputSub2ApiDefaultProxy.addEventListener('blur', () => {
+  saveSettings({ silent: true }).catch(() => { });
+});
+
 inputEmailPrefix.addEventListener('input', () => {
   maybeClearGeneratedAliasAfterEmailPrefixChange().catch(() => { });
   syncManagedAliasBaseEmailDraftFromInput();

+ 9 - 0
tests/background-account-history-settings.test.js

@@ -61,6 +61,7 @@ const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
 const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
 const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
 const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
+const DEFAULT_SUB2API_PROXY_NAME = 'shadowrocket';
 const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
 const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
 const VERIFICATION_RESEND_COUNT_MIN = 0;
@@ -109,4 +110,12 @@ return {
     api.normalizeAccountRunHistoryHelperBaseUrl(''),
     'http://127.0.0.1:17373'
   );
+  assert.equal(
+    api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ''),
+    'shadowrocket'
+  );
+  assert.equal(
+    api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
+    'proxy-a'
+  );
 });

+ 1 - 0
tests/sidepanel-account-records-manager.test.js

@@ -82,6 +82,7 @@ test('sidepanel html contains account records overlay and manager script', () =>
   assert.match(html, /id="account-records-list"/);
   assert.match(html, /id="account-records-stats"/);
   assert.match(html, /id="btn-clear-account-records"/);
+  assert.match(html, /id="input-sub2api-default-proxy"/);
   assert.notEqual(managerIndex, -1);
   assert.notEqual(sidepanelIndex, -1);
   assert.ok(managerIndex < sidepanelIndex);

+ 178 - 0
tests/sub2api-panel-proxy.test.js

@@ -0,0 +1,178 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+
+function createJsonResponse(payload, status = 200) {
+  return {
+    ok: status >= 200 && status < 300,
+    status,
+    text: async () => JSON.stringify(payload),
+  };
+}
+
+function createSub2ApiPanelContext(fetchCalls = []) {
+  const storage = new Map();
+  const documentElement = {
+    attrs: new Map(),
+    getAttribute(name) {
+      return this.attrs.get(name) || null;
+    },
+    setAttribute(name, value) {
+      this.attrs.set(name, String(value));
+    },
+  };
+
+  const context = {
+    URL,
+    console: { log() {} },
+    setTimeout() {},
+    document: { documentElement },
+    location: {
+      href: 'https://sub.example/admin/accounts',
+      origin: 'https://sub.example',
+      pathname: '/admin/accounts',
+      replace() {},
+    },
+    chrome: {
+      runtime: {
+        onMessage: { addListener() {} },
+        sendMessage: async () => ({
+          email: 'flow@example.com',
+          sub2apiDefaultProxyName: 'shadowrocket',
+        }),
+      },
+    },
+    localStorage: {
+      setItem(key, value) { storage.set(`local:${key}`, String(value)); },
+      removeItem(key) { storage.delete(`local:${key}`); },
+    },
+    sessionStorage: {
+      removeItem(key) { storage.delete(`session:${key}`); },
+    },
+    log() {},
+    reportComplete(step, payload) {
+      context.completed.push({ step, payload });
+    },
+    reportReady() {},
+    reportError() {},
+    resetStopState() {},
+    throwIfStopped() {},
+    isStopError() { return false; },
+    completed: [],
+    fetch: async (url, options = {}) => {
+      const parsed = new URL(url);
+      const body = options.body ? JSON.parse(options.body) : null;
+      fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body });
+
+      if (parsed.pathname === '/api/v1/auth/login') {
+        return createJsonResponse({
+          code: 0,
+          data: {
+            access_token: 'admin-token',
+            refresh_token: 'refresh-admin',
+            expires_in: 3600,
+            user: { id: 1 },
+          },
+        });
+      }
+      if (parsed.pathname === '/api/v1/admin/groups/all') {
+        return createJsonResponse({
+          code: 0,
+          data: [{ id: 5, name: 'codex', platform: 'openai' }],
+        });
+      }
+      if (parsed.pathname === '/api/v1/admin/proxies/all') {
+        return createJsonResponse({
+          code: 0,
+          data: [{
+            id: 7,
+            name: 'shadowrocket',
+            protocol: 'socks5',
+            host: '127.0.0.1',
+            port: 1080,
+            status: 'active',
+          }],
+        });
+      }
+      if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') {
+        return createJsonResponse({
+          code: 0,
+          data: {
+            auth_url: 'https://auth.openai.com/oauth?state=oauth-state',
+            session_id: 'session-1',
+            state: 'oauth-state',
+          },
+        });
+      }
+      if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
+        return createJsonResponse({
+          code: 0,
+          data: {
+            access_token: 'openai-access',
+            refresh_token: 'openai-refresh',
+            expires_at: 1770000000,
+            email: 'flow@example.com',
+          },
+        });
+      }
+      if (parsed.pathname === '/api/v1/admin/accounts') {
+        return createJsonResponse({
+          code: 0,
+          data: { id: 11 },
+        });
+      }
+
+      return createJsonResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
+    },
+  };
+
+  vm.createContext(context);
+  vm.runInContext(fs.readFileSync('content/sub2api-panel.js', 'utf8'), context);
+  return context;
+}
+
+test('SUB2API step 1 selects the configured default proxy before generating OAuth URL', async () => {
+  const fetchCalls = [];
+  const context = createSub2ApiPanelContext(fetchCalls);
+
+  const result = await vm.runInContext(`
+    step1_generateOpenAiAuthUrl({
+      sub2apiEmail: 'admin@example.com',
+      sub2apiPassword: 'secret',
+      sub2apiGroupName: 'codex',
+      sub2apiDefaultProxyName: 'shadowrocket'
+    }, { report: false })
+  `, context);
+
+  const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url');
+  assert.equal(result.sub2apiProxyId, 7);
+  assert.equal(generateCall.body.proxy_id, 7);
+});
+
+test('SUB2API step 10 uses the same proxy for code exchange and account creation', async () => {
+  const fetchCalls = [];
+  const context = createSub2ApiPanelContext(fetchCalls);
+
+  await vm.runInContext(`
+    step9_submitOpenAiCallback({
+      localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
+      sub2apiUrl: 'https://sub.example/admin/accounts',
+      sub2apiEmail: 'admin@example.com',
+      sub2apiPassword: 'secret',
+      sub2apiGroupName: 'codex',
+      sub2apiSessionId: 'session-1',
+      sub2apiOAuthState: 'oauth-state',
+      sub2apiGroupId: 5,
+      sub2apiProxyId: 7
+    })
+  `, context);
+
+  const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
+  const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
+
+  assert.equal(exchangeCall.body.proxy_id, 7);
+  assert.equal(createCall.body.proxy_id, 7);
+  assert.equal(createCall.body.group_ids[0], 5);
+  assert.equal(context.completed[0].step, 10);
+});