起初是觉得sub的添加模型太不方便了,不能像newapi那样获取上游模型列表,默认填充的又一堆不能用的模型,手动一个个勾选太麻烦了。然后又不想二开sub(更新太频繁了,哪一天说不定要处理冲突,想想就麻烦)。于是想到了油猴脚本:
大概思路是,对于“添加账号”,就依赖页面上输入的apikey+apiurl调用/v1/models接口获取模型列表;对于“编辑账号”,就从请求返回的数据中获取apikey+apiurl调用/v1/models接口。
安全性:完全没问题,都是你浏览器直接向你的上游渠道发起的请求,不经过任何中间商。(把代码丢给ai分析下安全性风险即可)
效果如图:

然后是代码:
// ==UserScript==
// @name sub2api API Key 模型白名单自动填充
// @namespace https://github.com/Wei-Shaw/sub2api
// @version 0.1.0
// @description 在 sub2api 添加/编辑 API Key 账号时,从上游 /v1/models 获取模型并替换模型白名单
// @match *://*/admin/accounts*
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const API_BASE = '/api/v1';
const PANEL_ATTR = 'data-s2a-model-loader';
const BUTTON_TEXT = '从 /v1/models 获取并替换白名单';
const HELP_TEXT = '适用于 API Key 接入账号;编辑账号时会从账号详情接口读取已保存的 Key。';
let lastEditId = null;
let busy = false;
let scanTimer = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const qsa = (root, selector) => Array.from(root.querySelectorAll(selector));
const textOf = (node) => (node?.textContent || '').replace(/\s+/g, ' ').trim();
function isVisible(el) {
if (!el || !(el instanceof Element)) return false;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
}
function isBefore(a, b) {
return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
}
function activeDialog() {
const dialogs = qsa(document, '[role="dialog"][aria-modal="true"], .modal-overlay');
return dialogs.filter(isVisible).at(-1) || null;
}
function dialogTitle(dialog) {
return textOf(dialog.querySelector('.modal-title, h3, [id^="modal-title"]'));
}
function isEditDialog(dialog) {
return Boolean(dialog.querySelector('#edit-account-form')) || /编辑|Edit/i.test(dialogTitle(dialog));
}
function accountNameFromDialog(dialog) {
const input = dialog.querySelector('input[data-tour="edit-account-form-name"], input[data-tour="account-form-name"]');
return (input?.value || '').trim();
}
function findButtonByText(root, patterns) {
return qsa(root, 'button').find((button) => {
if (!isVisible(button)) return false;
const text = textOf(button);
return patterns.some((pattern) => pattern.test(text));
}) || null;
}
function whitelistButton(dialog) {
return findButtonByText(dialog, [/^模型白名单$/i, /^Model Whitelist$/i, /^Model whitelist$/i]);
}
function customModelInput(dialog) {
const inputs = qsa(dialog, 'input').filter(isVisible);
return inputs.find((input) => /输入自定义模型名称|Enter custom model name/i.test(input.placeholder || '')) || null;
}
function whitelistRoot(dialog) {
const input = customModelInput(dialog);
return input?.closest('.mb-3')?.parentElement || dialog;
}
function findApiKeyContext(dialog) {
const whitelist = whitelistButton(dialog);
if (!whitelist) return null;
const inputsBeforeWhitelist = qsa(dialog, 'input')
.filter((input) => isVisible(input) && isBefore(input, whitelist));
const apiKeyInput = inputsBeforeWhitelist
.filter((input) => (input.getAttribute('type') || '').toLowerCase() === 'password')
.at(-1);
if (!apiKeyInput) return null;
const apiKeyIndex = inputsBeforeWhitelist.indexOf(apiKeyInput);
const baseUrlInput = inputsBeforeWhitelist
.slice(0, apiKeyIndex)
.reverse()
.find((input) => {
const type = (input.getAttribute('type') || 'text').toLowerCase();
const value = `${input.value || ''} ${input.placeholder || ''}`;
return ['text', 'url', 'search', ''].includes(type) && /https?:\/\//i.test(value);
});
if (!baseUrlInput) return null;
return { baseUrlInput, apiKeyInput, whitelist };
}
function statusEl(panel) {
return panel.querySelector('[data-s2a-status]');
}
function setStatus(panel, message, tone = 'muted') {
const el = statusEl(panel);
if (!el) return;
el.textContent = message;
const colors = {
muted: '#6b7280',
info: '#2563eb',
success: '#059669',
error: '#dc2626'
};
el.style.color = colors[tone] || colors.muted;
}
function showToast(message, tone = 'info') {
const old = document.querySelector('[data-s2a-toast]');
old?.remove();
const toast = document.createElement('div');
toast.setAttribute('data-s2a-toast', '1');
toast.textContent = message;
const bg = tone === 'error' ? '#dc2626' : tone === 'success' ? '#059669' : '#2563eb';
Object.assign(toast.style, {
position: 'fixed',
right: '18px',
top: '18px',
zIndex: '999999',
maxWidth: '420px',
padding: '10px 14px',
borderRadius: '8px',
background: bg,
color: '#fff',
fontSize: '14px',
lineHeight: '1.45',
boxShadow: '0 10px 30px rgba(0,0,0,.22)'
});
document.body.appendChild(toast);
window.setTimeout(() => toast.remove(), 4500);
}
function injectPanel(dialog, ctx) {
const existing = dialog.querySelector(`[${PANEL_ATTR}]`);
if (existing) {
if (existing.__s2aApiKeyInput === ctx.apiKeyInput) return;
existing.remove();
}
const panel = document.createElement('div');
panel.setAttribute(PANEL_ATTR, '1');
panel.__s2aApiKeyInput = ctx.apiKeyInput;
Object.assign(panel.style, {
marginTop: '10px',
padding: '10px',
border: '1px solid rgba(37, 99, 235, .28)',
borderRadius: '8px',
background: 'rgba(37, 99, 235, .06)'
});
const row = document.createElement('div');
Object.assign(row.style, { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' });
const button = document.createElement('button');
button.type = 'button';
button.textContent = BUTTON_TEXT;
Object.assign(button.style, {
border: '0',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
background: '#2563eb',
color: '#fff',
fontSize: '13px',
fontWeight: '600'
});
const status = document.createElement('span');
status.setAttribute('data-s2a-status', '1');
status.textContent = HELP_TEXT;
Object.assign(status.style, { color: '#6b7280', fontSize: '12px' });
row.append(button, status);
panel.append(row);
button.addEventListener('click', () => runModelImport(dialog, panel));
const apiKeyField = ctx.apiKeyInput.closest('div') || ctx.apiKeyInput.parentElement;
apiKeyField?.insertAdjacentElement('afterend', panel);
}
function scheduleScan() {
window.clearTimeout(scanTimer);
scanTimer = window.setTimeout(scan, 80);
}
function scan() {
if (!location.pathname.startsWith('/admin/accounts')) return;
const dialog = activeDialog();
if (!dialog) return;
const ctx = findApiKeyContext(dialog);
const panel = dialog.querySelector(`[${PANEL_ATTR}]`);
if (!ctx) {
panel?.remove();
return;
}
injectPanel(dialog, ctx);
}
document.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest('button');
if (button && /编辑|Edit/i.test(textOf(button))) {
const row = button.closest('tr[data-row-id]');
const id = Number(row?.getAttribute('data-row-id'));
lastEditId = Number.isFinite(id) && id > 0 ? id : null;
}
scheduleScan();
}, true);
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
window.setInterval(scan, 1200);
scan();
function normalizeModelsUrl(baseUrl) {
const clean = String(baseUrl || '').trim().replace(/\/+$/, '');
if (!clean) throw new Error('请先填写 Base URL。');
if (/\/v1\/models$/i.test(clean)) return clean;
if (/\/v1$/i.test(clean)) return `${clean}/models`;
return `${clean}/v1/models`;
}
function unwrapApiEnvelope(payload) {
if (payload && typeof payload === 'object' && 'code' in payload && 'data' in payload) {
if (Number(payload.code) !== 0) {
throw new Error(payload.message || '后台接口返回错误。');
}
return payload.data;
}
return payload;
}
async function adminGet(path) {
const headers = { Accept: 'application/json' };
const token = localStorage.getItem('auth_token');
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(path, { headers, credentials: 'include' });
const bodyText = await response.text();
let payload = null;
try {
payload = bodyText ? JSON.parse(bodyText) : null;
} catch {
payload = null;
}
if (!response.ok) {
const message = payload?.message || payload?.detail || bodyText.slice(0, 180) || response.statusText;
throw new Error(`后台接口请求失败(HTTP ${response.status}):${message}`);
}
return unwrapApiEnvelope(payload);
}
async function resolveEditAccount(dialog) {
if (lastEditId) {
try {
const account = await adminGet(`${API_BASE}/admin/accounts/${lastEditId}`);
if (account?.type === 'apikey') return account;
} catch {
// 继续走名称搜索兜底。
}
}
const name = accountNameFromDialog(dialog);
if (!name) {
throw new Error('无法识别当前编辑账号,请从账号列表点击“编辑”打开弹窗后重试。');
}
const list = await adminGet(`${API_BASE}/admin/accounts?page=1&page_size=50&search=${encodeURIComponent(name)}`);
const items = Array.isArray(list?.items) ? list.items : Array.isArray(list) ? list : [];
const matches = items.filter((account) => {
return account?.type === 'apikey' && String(account?.name || '').trim() === name;
});
if (matches.length !== 1) {
throw new Error(`无法通过账号名唯一定位账号(匹配到 ${matches.length} 个),请从账号列表点击“编辑”后立即重试。`);
}
return await adminGet(`${API_BASE}/admin/accounts/${matches[0].id}`);
}
async function getApiKey(dialog, ctx) {
const typed = (ctx.apiKeyInput.value || '').trim();
if (typed) return typed;
if (!isEditDialog(dialog)) {
throw new Error('新增账号时请先填写 API Key。');
}
const account = await resolveEditAccount(dialog);
if (account?.type !== 'apikey') {
throw new Error('当前账号不是 API Key 接入账号。');
}
const saved = String(account?.credentials?.api_key || '').trim();
if (!saved) {
throw new Error('账号详情里没有可用的已保存 API Key,请在表单里重新填写 API Key。');
}
return saved;
}
function requestUpstreamModels(url, apiKey) {
if (typeof GM_xmlhttpRequest !== 'function') {
throw new Error('当前油猴环境不支持 GM_xmlhttpRequest,请确认脚本授权是否生效。');
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 30000,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`,
'x-api-key': apiKey,
'x-goog-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
onload: (response) => {
const text = response.responseText || '';
if (response.status < 200 || response.status >= 300) {
reject(new Error(`上游 /v1/models 请求失败(HTTP ${response.status}):${text.slice(0, 240) || response.statusText}`));
return;
}
try {
resolve(text ? JSON.parse(text) : null);
} catch {
reject(new Error('上游 /v1/models 返回的不是合法 JSON。'));
}
},
onerror: () => reject(new Error('上游 /v1/models 网络请求失败。')),
ontimeout: () => reject(new Error('上游 /v1/models 请求超时。'))
});
});
}
function normalizeModelId(value) {
let model = String(value || '').trim();
if (!model) return '';
const marker = '/models/';
const markerIndex = model.lastIndexOf(marker);
if (markerIndex >= 0) model = model.slice(markerIndex + marker.length);
model = model.replace(/^models\//i, '');
if (/:[A-Za-z]+/.test(model)) model = model.split(':')[0];
return model.trim();
}
function looksLikeModelKey(key) {
const value = normalizeModelId(key);
if (!value || /^(data|items|models|object|has_more|total)$/i.test(value)) return false;
return /^[A-Za-z0-9][A-Za-z0-9._:@/+-]*$/.test(value);
}
function extractModelIds(payload) {
const result = [];
const seen = new Set();
function add(value) {
const model = normalizeModelId(value);
if (!model || seen.has(model)) return;
seen.add(model);
result.push(model);
}
function visit(value, hint = '') {
if (value == null) return;
if (typeof value === 'string') {
add(value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => visit(item, hint));
return;
}
if (typeof value !== 'object') return;
const direct = value.id || value.name || value.model || value.model_id || value.value || value.slug;
if (typeof direct === 'string') add(direct);
['data', 'models', 'items'].forEach((key) => {
if (value[key] !== undefined) visit(value[key], key);
});
if (/^(models|items)$/i.test(hint)) {
Object.entries(value).forEach(([key, item]) => {
const before = result.length;
visit(item, key);
if (result.length === before && looksLikeModelKey(key)) add(key);
});
}
}
visit(payload, 'root');
return result;
}
async function waitFor(fn, timeoutMs = 3000) {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
const value = fn();
if (value) return value;
await sleep(50);
}
return null;
}
function setNativeValue(input, value) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function addModelButton(input, dialog) {
const row = input.closest('.flex') || input.parentElement;
const rowButton = row ? findButtonByText(row, [/^(填入|Add)$/i]) : null;
if (rowButton) return rowButton;
return findButtonByText(whitelistRoot(dialog), [/^(填入|Add)$/i]);
}
async function clearWhitelist(dialog) {
const root = whitelistRoot(dialog);
const clearButton = findButtonByText(root, [/清除所有模型/i, /清空模型/i, /^Clear all models$/i]);
if (clearButton) {
clearButton.click();
await sleep(120);
return;
}
const removeButtons = qsa(root, 'button').filter((button) => {
return isVisible(button) && !textOf(button) && button.closest('span') && button.querySelector('svg');
});
removeButtons.forEach((button) => button.click());
await sleep(120);
}
async function replaceWhitelist(dialog, ids, panel) {
const whitelist = whitelistButton(dialog);
if (!whitelist) throw new Error('没有找到“模型白名单”按钮。');
whitelist.click();
const input = await waitFor(() => customModelInput(dialog));
if (!input) throw new Error('没有找到自定义模型输入框,请确认当前处于模型白名单模式。');
await clearWhitelist(dialog);
for (let i = 0; i < ids.length; i += 1) {
const freshInput = customModelInput(dialog) || input;
const addButton = addModelButton(freshInput, dialog);
if (!addButton) throw new Error('没有找到模型“填入”按钮。');
setNativeValue(freshInput, ids[i]);
addButton.click();
if ((i + 1) % 20 === 0 || i === ids.length - 1) {
setStatus(panel, `正在填充模型:${i + 1}/${ids.length}`, 'info');
await sleep(30);
} else {
await sleep(8);
}
}
}
async function runModelImport(dialog, panel) {
if (busy) return;
busy = true;
const ctx = findApiKeyContext(dialog);
const button = panel.querySelector('button');
const oldText = button?.textContent || BUTTON_TEXT;
if (button) {
button.disabled = true;
button.textContent = '正在获取模型...';
button.style.opacity = '0.72';
button.style.cursor = 'wait';
}
try {
if (!ctx) throw new Error('当前弹窗不是 API Key 接入账号,或页面结构未找到 Base URL/API Key/模型白名单。');
const baseUrl = (ctx.baseUrlInput.value || ctx.baseUrlInput.placeholder || '').trim();
const modelsUrl = normalizeModelsUrl(baseUrl);
const apiKey = await getApiKey(dialog, ctx);
setStatus(panel, `正在请求:${modelsUrl}`, 'info');
const payload = await requestUpstreamModels(modelsUrl, apiKey);
const ids = extractModelIds(payload);
if (ids.length === 0) {
throw new Error('上游返回中没有识别到模型 ID。');
}
setStatus(panel, `已获取 ${ids.length} 个模型,正在替换白名单...`, 'info');
await replaceWhitelist(dialog, ids, panel);
const message = `已从 /v1/models 获取并替换 ${ids.length} 个模型。请确认后保存账号。`;
setStatus(panel, message, 'success');
showToast(message, 'success');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(panel, message, 'error');
showToast(message, 'error');
} finally {
if (button) {
button.disabled = false;
button.textContent = oldText;
button.style.opacity = '1';
button.style.cursor = 'pointer';
}
busy = false;
}
}
})();
1 个帖子 - 1 位参与者