OpenRouter 模型筛选器

OpenRouter 模型信息筛选和获取工具

// ==UserScript==
// @name         OpenRouter 模型筛选器
// @namespace    openrouter-model-filter
// @version      2025.0.2
// @description  OpenRouter 模型信息筛选和获取工具
// @author       delph1s
// @license      MIT
// @icon         https://openrouter.ai/favicon.ico
// @match        https://openrouter.ai/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    let isExpanded = false;
    let extractedModels = [];

    // 创建样式
    function createStyles() {
        const style = document.createElement('style');
        style.textContent = `
            /* 主容器 */
            #or-filter-container {
                position: fixed !important;
                bottom: 14px !important;
                right: 14px !important;
                z-index: 99999 !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
            }

            /* 浮动按钮 */
            #or-filter-container .or-toggle-btn {
                width: 28px !important;
                height: 28px !important;
                border-radius: 14px !important;
                background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.1)) !important;
                backdrop-filter: blur(10px) !important;
                -webkit-backdrop-filter: blur(10px) !important;
                border: 1px solid rgba(255,255,255,0.2) !important;
                box-shadow: 0 8px 32px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.05) !important;
                cursor: pointer !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
                transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
                color: #333 !important;
                font-size: 14px !important;
                font-weight: 600 !important;
                margin: 0 !important;
                padding: 0 !important;
            }

            #or-filter-container .or-toggle-btn:hover {
                transform: scale(1.2) !important;
                box-shadow: 0 12px 40px rgba(0,0,0,0.15), 0 4px 12px rgba(0,0,0,0.1) !important;
            }

            /* 主面板 */
            #or-filter-container .or-panel {
                position: absolute !important;
                bottom: 0 !important;
                right: 0 !important;
                width: 380px !important;
                max-height: 85vh !important;
                background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.1)) !important;
                backdrop-filter: blur(10px) !important;
                -webkit-backdrop-filter: blur(10px) !important;
                border-radius: 14px !important;
                border: 1px solid rgba(255,255,255,0.3) !important;
                box-shadow: 0 20px 60px rgba(0,0,0,0.1), 0 8px 24px rgba(0,0,0,0.05) !important;
                padding: 24px !important;
                transform: scale(0.8) translateX(380px) !important;
                opacity: 0 !important;
                visibility: hidden !important;
                transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
                overflow-y: auto !important;
                margin: 0 !important;
            }

            #or-filter-container .or-panel.expanded {
                transform: scale(1) translateX(0) !important;
                opacity: 1 !important;
                visibility: visible !important;
            }

            /* 标题 */
            #or-filter-container .or-title {
                font-size: 20px !important;
                font-weight: 700 !important;
                color: #1d1d1f !important;
                margin: 0 0 14px 0 !important;
                padding: 0 !important;
                text-align: center !important;
                background: linear-gradient(135deg, #FF6B35, #F7931E) !important;
                -webkit-background-clip: text !important;
                -webkit-text-fill-color: transparent !important;
                background-clip: text !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
                line-height: 1.2 !important;
                border: none !important;
            }

            /* 关闭按钮 */
            #or-filter-container .or-close-btn {
                position: absolute !important;
                top: 14px !important;
                right: 14px !important;
                width: 24px !important;
                height: 24px !important;
                border-radius: 14px !important;
                background: rgba(142, 142, 147, 0) !important;
                color: #8e8e93 !important;
                cursor: pointer !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
                font-size: 14px !important;
                transition: all 0.3s ease !important;
                margin: 0 !important;
                padding: 0 !important;
                border: none !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
            }

            #or-filter-container .or-close-btn:hover {
                background: rgba(142, 142, 147, 0.2) !important;
                color: #1d1d1f !important;
            }

            /* 表单组 */
            #or-filter-container .or-form-group {
                line-height: 0 !important;
                margin: 0 0 14px 0 !important;
                padding: 0 !important;
            }

            #or-filter-container .or-label {
                display: block !important;
                font-size: 16px !important;
                font-weight: 600 !important;
                color: #1d1d1f !important;
                margin: 0 0 7px 0 !important;
                padding: 0 !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
                line-height: 1.2 !important;
                border: none !important;
                background: none !important;
            }

            /* 输入框和选择框 */
            #or-filter-container .or-input,
            #or-filter-container .or-select {
                width: 100% !important;
                padding: 8px 12px !important;
                background: rgba(142, 142, 147, 0) !important;
                border: 1px solid rgba(255, 107, 53, 0.5) !important;
                border-radius: 7px !important;
                font-size: 14px !important;
                color: #1d1d1f !important;
                transition: all 0.2s ease !important;
                outline: none !important;
                margin: 0 !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
                box-sizing: border-box !important;
            }

            #or-filter-container .or-input:focus,
            #or-filter-container .or-select:focus {
                background: rgba(255, 255, 255, 0) !important;
                border-color: rgba(255, 107, 53, 1) !important;
            }

            #or-filter-container .or-input::placeholder {
                color: #8e8e93 !important;
            }

            /* 按钮 */
            #or-filter-container .or-btn {
                width: 100% !important;
                border-radius: 7px !important;
                font-size: 14px !important;
                font-weight: 600 !important;
                cursor: pointer !important;
                transition: all 0.3s ease !important;
                margin: 0 0 14px 0 !important;
                display: flex !important;
                align-items: center !important;
                justify-content: center !important;
                outline: none !important;
                padding: 8px 12px !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
                box-sizing: border-box !important;
                border: none !important;
            }

            #or-filter-container .or-btn-primary {
                background: linear-gradient(135deg, #FF6B35BC, #F7931EBC) !important;
                color: white !important;
            }

            #or-filter-container .or-btn-primary:hover {
                transform: translateY(-1px) !important;
                box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3) !important;
            }

            #or-filter-container .or-btn-success {
                background: linear-gradient(135deg, #34C759BC, #30D158BC) !important;
                color: white !important;
            }

            #or-filter-container .or-btn-success:hover:not(:disabled) {
                transform: translateY(-1px) !important;
                box-shadow: 0 8px 25px rgba(52, 199, 89, 0.3) !important;
            }

            #or-filter-container .or-btn:disabled {
                opacity: 0.5 !important;
                cursor: not-allowed !important;
                transform: none !important;
            }

            /* 统计信息 */
            #or-filter-container .or-stats {
                text-align: center !important;
                font-size: 12px !important;
                color: #8e8e93 !important;
                margin: 0 0 14px 0 !important;
                padding: 8px 12px !important;
                background: rgba(142, 142, 147, 0.08) !important;
                border-radius: 7px !important;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif !important;
                font-weight: 400 !important;
                border: none !important;
                line-height: 1.3 !important;
            }

            /* 结果区域 */
            #or-filter-container .or-result-area {
                max-height: 200px !important;
                overflow-y: auto !important;
                border-radius: 12px !important;
                background: rgba(142, 142, 147, 0) !important;
                border: 1px solid rgba(142, 142, 147, 0.1) !important;
                margin: 0 !important;
                padding: 0 !important;
            }

            #or-filter-container .or-textarea {
                width: 100% !important;
                height: 200px !important;
                padding: 16px !important;
                border: none !important;
                background: transparent !important;
                color: #1d1d1f !important;
                font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace !important;
                font-size: 12px !important;
                line-height: 1.5 !important;
                resize: none !important;
                outline: none !important;
                margin: 0 !important;
                box-sizing: border-box !important;
            }

            #or-filter-container .or-textarea::placeholder {
                color: #8e8e93 !important;
            }

            /* 滚动条样式 */
            #or-filter-container .or-result-area::-webkit-scrollbar,
            #or-filter-container .or-panel::-webkit-scrollbar {
                width: 6px !important;
            }

            #or-filter-container .or-result-area::-webkit-scrollbar-track,
            #or-filter-container .or-panel::-webkit-scrollbar-track {
                background: rgba(142, 142, 147, 0.1) !important;
                border-radius: 3px !important;
            }

            #or-filter-container .or-result-area::-webkit-scrollbar-thumb,
            #or-filter-container .or-panel::-webkit-scrollbar-thumb {
                background: rgba(142, 142, 147, 0.3) !important;
                border-radius: 3px !important;
            }

            #or-filter-container .or-result-area::-webkit-scrollbar-thumb:hover,
            #or-filter-container .or-panel::-webkit-scrollbar-thumb:hover {
                background: rgba(142, 142, 147, 0.5) !important;
            }

            /* 动画 */
            @keyframes or-bounce-in {
                0% { transform: scale(0.3); opacity: 0; }
                50% { transform: scale(1.05); }
                70% { transform: scale(0.9); }
                100% { transform: scale(1); opacity: 1; }
            }

            #or-filter-container .or-toggle-btn.or-animate {
                animation: or-bounce-in 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
            }
        `;
        document.head.appendChild(style);
    }

    // 创建界面
    function createFilterUI() {
        const container = document.createElement('div');
        container.id = 'or-filter-container';

        container.innerHTML = `
            <div class="or-toggle-btn or-animate" id="or-toggle">🔍</div>
            <div class="or-panel" id="or-panel">
                <div class="or-close-btn" id="or-close">×</div>
                <div class="or-title">OpenRouter模型筛选器</div>

                <div class="or-form-group">
                    <label class="or-label">关键词筛选</label>
                    <input type="text" id="or-keywords" class="or-input" placeholder="输入关键词,用逗号分隔,如:gpt,claude">
                </div>

                <div class="or-form-group">
                    <label class="or-label">最大价格 ($)</label>
                    <input type="number" id="or-max-price" class="or-input" placeholder="输入最大价格,0为免费" min="0" step="0.001" value="">
                </div>

                <div class="or-form-group">
                    <label class="or-label">模型厂商</label>
                    <select id="or-provider" class="or-select">
                        <option value="all">所有厂商</option>
                    </select>
                </div>

                <div class="or-form-group">
                    <label class="or-label">上下文长度</label>
                    <select id="or-context" class="or-select">
                        <option value="all">所有长度</option>
                        <option value="32000">≥32K</option>
                        <option value="64000">≥64K</option>
                        <option value="128000">≥128K</option>
                        <option value="200000">≥200K</option>
                    </select>
                </div>

                <div class="or-form-group">
                    <label class="or-label">输出格式</label>
                    <select id="or-output-format" class="or-select">
                        <option value="model_id">模型ID</option>
                        <option value="model_name">模型名称</option>
                        <option value="comma">逗号分隔</option>
                        <option value="newline">换行分隔</option>
                        <option value="detailed">详细信息</option>
                    </select>
                </div>

                <button id="or-refresh-btn" class="or-btn or-btn-primary">刷新数据</button>

                <button id="or-copy-btn" class="or-btn or-btn-success" disabled>复制结果</button>

                <div class="or-stats">
                    <div>找到的模型数量: <span id="or-model-count">0</span></div>
                    <div>总计模型: <span id="or-total-count">0</span></div>
                </div>

                <div class="or-result-area">
                    <textarea id="or-result-output" class="or-textarea" placeholder="筛选结果将显示在这里..." readonly></textarea>
                </div>
            </div>
        `;

        document.body.appendChild(container);

        // 绑定事件
        document.getElementById('or-toggle').addEventListener('click', togglePanel);
        document.getElementById('or-close').addEventListener('click', togglePanel);
        document.getElementById('or-refresh-btn').addEventListener('click', fetchModelData);
        document.getElementById('or-copy-btn').addEventListener('click', copyResults);

        // 实时筛选 - 价格变化时重新获取数据
        ['or-keywords', 'or-provider', 'or-context', 'or-output-format'].forEach(id => {
            const element = document.getElementById(id);
            if (element) {
                element.addEventListener('change', filterModels);
                if (element.type === 'text') {
                    element.addEventListener('input', filterModels);
                }
            }
        });

        // 价格输入变化时重新获取数据
        document.getElementById('or-max-price').addEventListener('change', fetchModelData);
        document.getElementById('or-max-price').addEventListener('input', debounce(fetchModelData, 1000));

        // 初始加载数据
        fetchModelData();
    }

    // 切换面板
    function togglePanel() {
        const panel = document.getElementById('or-panel');
        isExpanded = !isExpanded;

        if (isExpanded) {
            panel.classList.add('expanded');
        } else {
            panel.classList.remove('expanded');
        }
    }

    // 获取模型数据
    async function fetchModelData() {
        console.log('开始获取OpenRouter模型数据...');

        const refreshBtn = document.getElementById('or-refresh-btn');
        refreshBtn.disabled = true;
        refreshBtn.textContent = '获取中...';

        try {
            // 根据用户输入的最大价格动态构建API URL
            const maxPriceInput = document.getElementById('or-max-price').value;
            let apiUrl = "https://openrouter.ai/api/frontend/models/find";

            // 如果用户输入了最大价格,添加到API参数中
            if (maxPriceInput !== '') {
                apiUrl += `?max_price=${maxPriceInput}`;
            }

            const response = await fetch(apiUrl, {
                "headers": {
                    "accept": "*/*",
                    "accept-language": "en-US,en;q=0.9",
                },
                "method": "GET",
                "mode": "cors",
                "credentials": "include"
            });

            const data = await response.json();

            if (data && data.data && data.data.models) {
                extractedModels = data.data.models
                    .filter(model => model.endpoint && model.endpoint.model_variant_slug) // 过滤掉endpoint为null的模型
                    .map(model => {
                        // 按照你原始代码的逻辑,使用 model_variant_slug
                        const modelId = model.endpoint.model_variant_slug;

                        return {
                            id: modelId,
                            name: model.name || modelId,
                            provider: model.endpoint.provider_name || model.endpoint.provider_display_name || 'Unknown',
                            pricing: {
                                prompt: parseFloat(model.endpoint.pricing?.prompt || 0),
                                completion: parseFloat(model.endpoint.pricing?.completion || 0),
                                image: parseFloat(model.endpoint.pricing?.image || 0),
                                request: parseFloat(model.endpoint.pricing?.request || 0)
                            },
                            context_length: parseInt(model.endpoint.context_length || 0),
                            description: model.description || '',
                            input_modalities: model.input_modalities || ['text'],
                            output_modalities: model.output_modalities || ['text'],
                            is_free: model.endpoint.is_free || false,
                            raw: model
                        };
                    });

                console.log(`成功获取 ${extractedModels.length} 个模型`);

                // 更新厂商选择器
                updateProviderSelect();

                // 执行筛选
                filterModels();

                document.getElementById('or-total-count').textContent = extractedModels.length;
            } else {
                throw new Error('API返回数据格式异常');
            }

        } catch (error) {
            console.error('获取模型数据失败:', error);
            document.getElementById('or-result-output').value = `获取数据失败: ${error.message}`;
        } finally {
            refreshBtn.disabled = false;
            refreshBtn.textContent = '刷新数据';
        }
    }

    // 更新厂商选择器
    function updateProviderSelect() {
        const providerSelect = document.getElementById('or-provider');
        const providers = [...new Set(extractedModels.map(model => model.provider))].sort();

        // 保留当前选择的值
        const currentValue = providerSelect.value;

        // 清空选项(保留"所有厂商")
        providerSelect.innerHTML = '<option value="all">所有厂商</option>';

        // 添加厂商选项
        providers.forEach(provider => {
            const option = document.createElement('option');
            option.value = provider;
            option.textContent = provider;
            providerSelect.appendChild(option);
        });

        // 恢复选择的值(如果还存在)
        if (currentValue && [...providerSelect.options].some(opt => opt.value === currentValue)) {
            providerSelect.value = currentValue;
        }
    }

    // 检查模型是否匹配关键词
    function matchesKeywords(model, keywords) {
        if (!keywords.trim()) return true;

        const keywordList = keywords.split(',').map(k => k.trim().toLowerCase()).filter(k => k);
        const searchText = `${model.name} ${model.id} ${model.description}`.toLowerCase();

        return keywordList.some(keyword => searchText.includes(keyword));
    }

    // 获取模型最大价格
    function getModelMaxPrice(model) {
        // 首先检查是否明确标记为免费
        if (model.is_free) {
            return 0;
        }

        // 获取所有非零价格
        const prices = Object.values(model.pricing).filter(price => !isNaN(price) && price > 0);

        // 如果没有任何价格大于0,认为是免费的
        if (prices.length === 0) {
            return 0;
        }

        // 返回最高价格
        return Math.max(...prices);
    }

    // 筛选模型
    function filterModels() {
        if (extractedModels.length === 0) {
            document.getElementById('or-result-output').value = '没有模型数据,请点击"刷新数据"获取';
            document.getElementById('or-model-count').textContent = '0';
            return;
        }

        const keywords = document.getElementById('or-keywords').value;
        const maxPriceInput = document.getElementById('or-max-price').value;
        const maxPrice = maxPriceInput === '' ? Infinity : parseFloat(maxPriceInput);
        const provider = document.getElementById('or-provider').value;
        const contextLength = parseInt(document.getElementById('or-context').value) || 0;
        const outputFormat = document.getElementById('or-output-format').value;

        console.log('筛选参数:', { keywords, maxPrice, provider, contextLength, outputFormat });

        const filteredModels = extractedModels.filter(model => {
            // 关键词筛选
            if (!matchesKeywords(model, keywords)) {
                return false;
            }

            // 厂商筛选
            if (provider !== 'all' && model.provider !== provider) {
                return false;
            }

            // 上下文长度筛选
            if (contextLength > 0 && model.context_length < contextLength) {
                return false;
            }

            // 注意:价格筛选已经在API层面完成,这里不需要再筛选价格

            return true;
        });

        console.log(`筛选后模型数量: ${filteredModels.length}`);

        // 按名称排序
        filteredModels.sort((a, b) => a.name.localeCompare(b.name));

        // 生成输出
        let output = '';
        switch (outputFormat) {
            case 'model_name':
                output = filteredModels.map(model => model.name).join('\n');
                break;
            case 'model_id':
                output = filteredModels.map(model => model.id).join('\n');
                break;
            case 'comma':
                output = filteredModels.map(model => model.id).join(', ');
                break;
            case 'newline':
                output = filteredModels.map(model => model.id).join('\n');
                break;
            case 'detailed':
                output = filteredModels.map(model => {
                    const maxPrice = getModelMaxPrice(model);
                    const priceText = maxPrice === 0 ? '免费' : `${maxPrice.toFixed(6)}`;
                    const contextText = model.context_length > 0 ? `${model.context_length.toLocaleString()}` : '未知';
                    const modalityText = model.input_modalities ? model.input_modalities.join('+') : 'text';
                    return `${model.name} | ${model.provider} | ${priceText} | ${contextText} tokens | ${modalityText} | ${model.id}`;
                }).join('\n');
                break;
            default:
                output = filteredModels.map(model => model.id).join('\n');
        }

        document.getElementById('or-result-output').value = output;
        document.getElementById('or-model-count').textContent = filteredModels.length;
        document.getElementById('or-copy-btn').disabled = filteredModels.length === 0;
    }

    // 复制结果
    function copyResults() {
        const textarea = document.getElementById('or-result-output');
        textarea.select();
        textarea.setSelectionRange(0, 99999);

        try {
            document.execCommand('copy');

            const btn = document.getElementById('or-copy-btn');
            const originalText = btn.textContent;
            btn.textContent = '已复制!';
            btn.style.background = 'linear-gradient(135deg, #FF9500, #FF6482)';

            setTimeout(() => {
                btn.textContent = originalText;
                btn.style.background = 'linear-gradient(135deg, #34C759, #30D158)';
            }, 2000);
        } catch (err) {
            console.error('复制失败:', err);
            alert('复制失败,请手动选择文本复制');
        }
    }

    // 初始化
    function init() {
        createStyles();
        createFilterUI();
    }

    // 页面加载完成后初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();