Add configurable SUB2API account priority

This commit is contained in:
initiatione
2026-05-06 17:53:07 +08:00
parent 9e6899f37c
commit 5014cb4de3
10 changed files with 254 additions and 1 deletions
+16
View File
@@ -242,6 +242,7 @@ const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro';
const DEFAULT_SUB2API_GROUP_NAME = 'codex';
const DEFAULT_SUB2API_PROXY_NAME = '';
const DEFAULT_SUB2API_ACCOUNT_PRIORITY = 1;
const CONTRIBUTION_SOURCE_CPA = 'cpa';
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
@@ -574,6 +575,7 @@ const PERSISTED_SETTING_DEFAULTS = {
sub2apiPassword: '',
sub2apiGroupName: DEFAULT_SUB2API_GROUP_NAME,
sub2apiGroupNames: DEFAULT_SUB2API_GROUP_NAMES,
sub2apiAccountPriority: DEFAULT_SUB2API_ACCOUNT_PRIORITY,
sub2apiDefaultProxyName: DEFAULT_SUB2API_PROXY_NAME,
ipProxyEnabled: false,
ipProxyService: DEFAULT_IP_PROXY_SERVICE,
@@ -2105,6 +2107,18 @@ function normalizeSub2ApiGroupNames(value = '') {
return names;
}
function normalizeSub2ApiAccountPriority(value, fallback = DEFAULT_SUB2API_ACCOUNT_PRIORITY) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isSafeInteger(numeric) || numeric < 1) {
const fallbackNumber = Number(fallback);
return Number.isSafeInteger(fallbackNumber) && fallbackNumber >= 1
? fallbackNumber
: DEFAULT_SUB2API_ACCOUNT_PRIORITY;
}
return numeric;
}
function normalizePersistentSettingValue(key, value) {
switch (key) {
case 'panelMode':
@@ -2125,6 +2139,8 @@ function normalizePersistentSettingValue(key, value) {
return String(value || '').trim();
case 'sub2apiGroupNames':
return normalizeSub2ApiGroupNames(value);
case 'sub2apiAccountPriority':
return normalizeSub2ApiAccountPriority(value);
case 'sub2apiDefaultProxyName':
return String(value || '').trim();
case 'ipProxyEnabled':
+1
View File
@@ -293,6 +293,7 @@
sub2apiPassword: state.sub2apiPassword,
sub2apiGroupName: groupName,
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
sub2apiAccountPriority: state.sub2apiAccountPriority,
logStep: 7,
},
}, {
+1
View File
@@ -380,6 +380,7 @@
sub2apiPassword: state.sub2apiPassword,
sub2apiGroupName: state.sub2apiGroupName,
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
sub2apiAccountPriority: state.sub2apiAccountPriority,
sub2apiProxyId: state.sub2apiProxyId,
sub2apiSessionId: state.sub2apiSessionId,
sub2apiOAuthState: state.sub2apiOAuthState,
+17 -1
View File
@@ -252,6 +252,21 @@ function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) {
return SUB2API_DEFAULT_PROXY_NAME;
}
function resolveSub2ApiAccountPriority(payload = {}, backgroundState = {}) {
const candidate = payload.sub2apiAccountPriority !== undefined
? payload.sub2apiAccountPriority
: backgroundState.sub2apiAccountPriority;
const rawValue = String(candidate ?? '').trim();
if (!rawValue) {
return SUB2API_DEFAULT_PRIORITY;
}
const numeric = Number(rawValue);
if (!Number.isSafeInteger(numeric) || numeric < 1) {
throw new Error('SUB2API 账号优先级必须是大于等于 1 的整数。');
}
return numeric;
}
function normalizeProxyId(value) {
if (value === undefined || value === null || value === '') {
return null;
@@ -568,6 +583,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
const proxySelector = preferredProxyId || proxyPreference;
const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector) : null;
const proxyId = normalizeProxyId(proxy?.id);
const accountPriority = resolveSub2ApiAccountPriority(payload, backgroundState);
const storedGroupIds = Array.isArray(payload.sub2apiGroupIds)
? payload.sub2apiGroupIds
: (Array.isArray(backgroundState.sub2apiGroupIds) ? backgroundState.sub2apiGroupIds : []);
@@ -627,7 +643,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
type: 'oauth',
credentials,
concurrency: SUB2API_DEFAULT_CONCURRENCY,
priority: SUB2API_DEFAULT_PRIORITY,
priority: accountPriority,
rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
group_ids: groupIds,
auto_pause_on_expired: true,
+4
View File
@@ -210,6 +210,10 @@
<button id="btn-add-sub2api-group" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-sub2api-account-priority" style="display:none;">
<span class="data-label">优先级</span>
<input type="number" id="input-sub2api-account-priority" class="data-input mono" value="1" min="1" step="1" />
</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="留空则不使用代理;或填写代理名称 / ID" />
+40
View File
@@ -100,6 +100,8 @@ const btnSub2ApiGroupMenu = document.getElementById('btn-sub2api-group-menu');
const sub2ApiGroupCurrent = document.getElementById('sub2api-group-current');
const sub2ApiGroupMenu = document.getElementById('sub2api-group-menu');
const btnAddSub2ApiGroup = document.getElementById('btn-add-sub2api-group');
const rowSub2ApiAccountPriority = document.getElementById('row-sub2api-account-priority');
const inputSub2ApiAccountPriority = document.getElementById('input-sub2api-account-priority');
const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-proxy');
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
const rowIpProxyEnabled = document.getElementById('row-ip-proxy-enabled');
@@ -1150,6 +1152,15 @@ function normalizeSub2ApiGroupOptions(...sources) {
return normalizeEditableListValues(...sources);
}
function normalizeSub2ApiAccountPriorityValue(value) {
const rawValue = String(value ?? '').trim();
const numeric = Number(rawValue);
if (!rawValue || !Number.isSafeInteger(numeric) || numeric < 1) {
return 1;
}
return numeric;
}
function getSelectedSub2ApiGroupName() {
return String(inputSub2ApiGroup?.value || '').trim()
|| DEFAULT_SUB2API_GROUP_OPTIONS[0];
@@ -3181,6 +3192,12 @@ function collectSettingsPayload() {
if (sub2apiGroupNames.length === 0) {
appendSub2ApiGroupNames(['codex', 'openai-plus']);
}
const sub2apiAccountPriorityNormalizer = typeof normalizeSub2ApiAccountPriorityValue === 'function'
? normalizeSub2ApiAccountPriorityValue
: ((value) => {
const numeric = Number(String(value ?? '').trim());
return Number.isSafeInteger(numeric) && numeric >= 1 ? numeric : 1;
});
return {
...(contributionModeEnabled ? {} : {
panelMode: selectPanelMode.value,
@@ -3193,6 +3210,11 @@ function collectSettingsPayload() {
sub2apiPassword: inputSub2ApiPassword.value,
sub2apiGroupName: selectedSub2ApiGroupName,
sub2apiGroupNames,
sub2apiAccountPriority: sub2apiAccountPriorityNormalizer(
typeof inputSub2ApiAccountPriority !== 'undefined' && inputSub2ApiAccountPriority
? inputSub2ApiAccountPriority.value
: latestState?.sub2apiAccountPriority
),
sub2apiDefaultProxyName: inputSub2ApiDefaultProxy.value.trim(),
ipProxyEnabled: getSelectedIpProxyEnabledSafe(),
ipProxyService: selectedIpProxyService,
@@ -7550,6 +7572,9 @@ function applyAutoRunStatus(payload = currentAutoRun) {
if (typeof inputSignupPhone !== 'undefined' && inputSignupPhone) {
inputSignupPhone.disabled = locked;
}
if (typeof inputSub2ApiAccountPriority !== 'undefined' && inputSub2ApiAccountPriority) {
inputSub2ApiAccountPriority.disabled = locked;
}
inputAutoSkipFailures.disabled = scheduled;
const lockedRunCount = typeof getLockedRunCountFromEmailPool === 'function'
@@ -7847,6 +7872,9 @@ function applySettingsState(state) {
inputSub2ApiEmail.value = state?.sub2apiEmail || '';
inputSub2ApiPassword.value = state?.sub2apiPassword || '';
renderSub2ApiGroupOptions(state, state?.sub2apiGroupName || '');
if (typeof inputSub2ApiAccountPriority !== 'undefined' && inputSub2ApiAccountPriority) {
inputSub2ApiAccountPriority.value = String(normalizeSub2ApiAccountPriorityValue(state?.sub2apiAccountPriority));
}
inputSub2ApiDefaultProxy.value = state?.sub2apiDefaultProxyName || '';
const normalizedIpProxyService = resolveIpProxyService(state?.ipProxyService);
const normalizedIpProxyServiceProfiles = typeof normalizeIpProxyServiceProfiles === 'function'
@@ -9613,6 +9641,7 @@ function updatePanelModeUI() {
rowSub2ApiEmail.style.display = useSub2Api ? '' : 'none';
rowSub2ApiPassword.style.display = useSub2Api ? '' : 'none';
rowSub2ApiGroup.style.display = useSub2Api ? '' : 'none';
rowSub2ApiAccountPriority.style.display = useSub2Api ? '' : 'none';
rowSub2ApiDefaultProxy.style.display = useSub2Api ? '' : 'none';
rowCodex2ApiUrl.style.display = useCodex2Api ? '' : 'none';
rowCodex2ApiAdminKey.style.display = useCodex2Api ? '' : 'none';
@@ -10354,6 +10383,7 @@ const contributionModeManager = window.SidepanelContributionMode?.createContribu
rowPhoneVerificationEnabled,
rowCustomPassword,
rowLocalCpaStep9Mode,
rowSub2ApiAccountPriority,
rowSub2ApiDefaultProxy,
rowSub2ApiEmail,
rowSub2ApiGroup,
@@ -11700,6 +11730,16 @@ inputSub2ApiGroup.addEventListener('change', () => {
markSettingsDirty(true);
saveSettings({ silent: true }).catch(() => { });
});
inputSub2ApiAccountPriority.addEventListener('input', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
inputSub2ApiAccountPriority.addEventListener('blur', () => {
inputSub2ApiAccountPriority.value = String(normalizeSub2ApiAccountPriorityValue(inputSub2ApiAccountPriority.value));
saveSettings({ silent: true }).catch(() => { });
});
btnAddSub2ApiGroup?.addEventListener('click', () => {
handleAddSub2ApiGroup().catch((error) => {
showToast(error?.message || '添加 SUB2API 分组失败。', 'error');
@@ -116,3 +116,49 @@ test('panel bridge can request cpa oauth url via management api', async () => {
globalThis.fetch = originalFetch;
}
});
test('panel bridge forwards SUB2API account priority when requesting oauth url', async () => {
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
const sentMessages = [];
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
const bridge = api.createPanelBridge({
addLog: async () => {},
chrome: {
tabs: {
create: async () => ({ id: 72 }),
},
},
closeConflictingTabsForSource: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: (value) => value,
rememberSourceLastUrl: async () => {},
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return {
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
};
},
sendToContentScriptResilient: async () => ({}),
waitForTabUrlFamily: async () => ({ id: 72 }),
DEFAULT_SUB2API_GROUP_NAME: 'codex',
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
});
await bridge.requestOAuthUrlFromPanel({
panelMode: 'sub2api',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiAccountPriority: 3,
}, { logLabel: '步骤 7' });
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 3);
});
@@ -272,3 +272,34 @@ test('platform verify module sends Plus visible step 13 to SUB2API panel', async
assert.equal(sentMessages[0].message.step, 13);
});
test('platform verify module forwards SUB2API account priority to panel', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const sentMessages = [];
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
isTabAlive: async () => true,
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return { ok: true };
},
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
sub2apiAccountPriority: 2,
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 2);
});
@@ -0,0 +1,52 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
test('sidepanel exposes SUB2API account priority below group setting', () => {
assert.match(html, /id="row-sub2api-account-priority"/);
assert.match(html, /id="input-sub2api-account-priority"/);
assert.match(html, /<span class="data-label">优先级<\/span>/);
const inputTag = html.match(/<input[^>]*id="input-sub2api-account-priority"[^>]*>/)?.[0] || '';
assert.match(inputTag, /type="number"/);
assert.match(inputTag, /min="1"/);
assert.match(inputTag, /step="1"/);
assert.ok(
html.indexOf('id="row-sub2api-account-priority"') > html.indexOf('id="row-sub2api-group"'),
'priority row should be placed below the SUB2API group row'
);
assert.ok(
html.indexOf('id="row-sub2api-account-priority"') < html.indexOf('id="row-sub2api-default-proxy"'),
'priority row should remain above the SUB2API default proxy row'
);
});
test('sidepanel persists and locks SUB2API account priority setting', () => {
assert.match(
source,
/const rowSub2ApiAccountPriority = document\.getElementById\('row-sub2api-account-priority'\);/
);
assert.match(
source,
/const inputSub2ApiAccountPriority = document\.getElementById\('input-sub2api-account-priority'\);/
);
assert.match(source, /function normalizeSub2ApiAccountPriorityValue\(/);
assert.match(source, /const sub2apiAccountPriorityNormalizer = typeof normalizeSub2ApiAccountPriorityValue === 'function'/);
assert.match(source, /sub2apiAccountPriority: sub2apiAccountPriorityNormalizer\(/);
assert.match(
source,
/inputSub2ApiAccountPriority\.value = String\(normalizeSub2ApiAccountPriorityValue\(state\?\.sub2apiAccountPriority\)\);/
);
assert.match(source, /rowSub2ApiAccountPriority\.style\.display = useSub2Api \? '' : 'none';/);
assert.match(source, /inputSub2ApiAccountPriority\.disabled = locked;/);
assert.match(
source,
/inputSub2ApiAccountPriority\.addEventListener\('input', \(\) => \{[\s\S]*scheduleSettingsAutoSave\(\);[\s\S]*\}\);/
);
assert.match(
source,
/inputSub2ApiAccountPriority\.addEventListener\('blur', \(\) => \{[\s\S]*saveSettings\(\{ silent: true \}\)/
);
});
+46
View File
@@ -277,3 +277,49 @@ test('SUB2API step 10 omits proxy_id when no proxy is configured', async () => {
assert.equal(Object.hasOwn(exchangeCall.body, 'proxy_id'), false);
assert.equal(Object.hasOwn(createCall.body, 'proxy_id'), false);
});
test('SUB2API step 10 creates account with configured account priority', 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,
sub2apiAccountPriority: 3
})
`, context);
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
assert.equal(createCall.body.priority, 3);
});
test('SUB2API account priority must be an integer greater than or equal to 1', async () => {
const fetchCalls = [];
const context = createSub2ApiPanelContext(fetchCalls);
await assert.rejects(
() => 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,
sub2apiAccountPriority: 0
})
`, context),
/SUB2API 账号优先级必须是大于等于 1 的整数/
);
assert.equal(fetchCalls.some((call) => call.path === '/api/v1/admin/accounts'), false);
});