feat(integrations): add dynamic OpenRouter model loading

- Remove hardcoded OPENROUTER_MODEL_CHOICES from openrouter.py
- Add API endpoint /integrations/openrouter/models/ to fetch models dynamically
- Models loaded from OpenRouter API with free models (':free') at top
- Update OpenRouterIntegration model_name field (remove choices, blank=True)
- Add async buildForm() with dynamic_choices support
- Show asterisks (********) for saved API keys with helpful placeholder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 18:16:12 +03:00
parent 3aac83474b
commit 5ec5ee48d4
5 changed files with 173 additions and 57 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2026-01-23 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integrations', '0009_alter_glmintegration_model_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='openrouterintegration',
name='model_name',
field=models.CharField(blank=True, default='', help_text='Название используемой модели OpenRouter (загружается автоматически)', max_length=200, verbose_name='Название модели'),
),
]

View File

@@ -10,14 +10,6 @@ def validate_temperature(value):
raise ValidationError('Температура должна быть в диапазоне 0.0-2.0') raise ValidationError('Температура должна быть в диапазоне 0.0-2.0')
# Список доступных моделей OpenRouter (бесплатные)
OPENROUTER_MODEL_CHOICES = [
('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'),
('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'),
('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'),
('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'),
]
# Предустановленные значения температуры # Предустановленные значения температуры
OPENROUTER_TEMPERATURE_CHOICES = [ OPENROUTER_TEMPERATURE_CHOICES = [
(0.1, '0.1 - Очень консервативно'), (0.1, '0.1 - Очень консервативно'),
@@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration):
) )
model_name = models.CharField( model_name = models.CharField(
max_length=100, max_length=200,
default="xiaomi/mimo-v2-flash:free", default="",
choices=OPENROUTER_MODEL_CHOICES, blank=True,
verbose_name="Название модели", verbose_name="Название модели",
help_text="Название используемой модели OpenRouter" help_text="Название используемой модели OpenRouter (загружается автоматически)"
) )
temperature = models.FloatField( temperature = models.FloatField(

View File

@@ -6,6 +6,7 @@ from .views import (
get_integration_form_data, get_integration_form_data,
test_integration_connection, test_integration_connection,
RecommerceBatchSyncView, RecommerceBatchSyncView,
get_openrouter_models,
) )
app_name = 'integrations' app_name = 'integrations'
@@ -22,4 +23,7 @@ urlpatterns = [
# Синхронизация # Синхронизация
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"), path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
# OpenRouter модели
path("openrouter/models/", get_openrouter_models, name="openrouter_models"),
] ]

View File

@@ -1,7 +1,10 @@
import json import json
import logging
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST, require_GET
logger = logging.getLogger(__name__)
from user_roles.mixins import OwnerRequiredMixin from user_roles.mixins import OwnerRequiredMixin
from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration
@@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance):
return None return None
@require_GET
def get_openrouter_models(request):
"""
GET /settings/integrations/openrouter/models/
Возвращает список моделей OpenRouter (бесплатные сверху)
"""
import requests
try:
response = requests.get('https://openrouter.ai/api/v1/models', timeout=10)
response.raise_for_status()
data = response.json()
models = data.get('data', [])
# Разделить на бесплатные и платные
free_models = []
paid_models = []
for model in models:
model_id = model.get('id', '')
model_name = model.get('name', model_id)
if ':free' in model_id:
free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"})
else:
paid_models.append({'id': model_id, 'name': model_name})
# Бесплатные сверху
all_models = free_models + paid_models
return JsonResponse({'models': all_models})
except Exception as e:
logger.error(f"Error fetching OpenRouter models: {e}")
return JsonResponse({'error': str(e)}, status=500)
class RecommerceBatchSyncView(TemplateView): class RecommerceBatchSyncView(TemplateView):
""" """
API View для запуска массовой синхронизации с Recommerce. API View для запуска массовой синхронизации с Recommerce.
@@ -363,29 +404,33 @@ def get_form_fields_meta(model):
'label': getattr(field, 'verbose_name', field_name), 'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''), 'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True), 'required': not getattr(field, 'blank', True),
'type': 'text', # default 'type': 'password' if field_name == 'api_key' else 'text',
} }
# Определить тип поля
if 'BooleanField' in field.__class__.__name__:
field_info['type'] = 'checkbox'
elif 'URLField' in field.__class__.__name__:
field_info['type'] = 'url'
elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower():
field_info['type'] = 'password'
fields.append(field_info) fields.append(field_info)
elif field_name in ['model_name', 'temperature']:
elif field_name == 'temperature':
field = model._meta.get_field(field_name) field = model._meta.get_field(field_name)
field_info = { field_info = {
'name': field_name, 'name': field_name,
'label': getattr(field, 'verbose_name', field_name), 'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''), 'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True), 'required': not getattr(field, 'blank', True),
'type': 'select', # dropdown 'type': 'select',
'choices': getattr(field, 'choices', []) 'choices': getattr(field, 'choices', [])
} }
fields.append(field_info)
elif field_name == 'model_name':
field = model._meta.get_field(field_name)
field_info = {
'name': field_name,
'label': getattr(field, 'verbose_name', field_name),
'help_text': getattr(field, 'help_text', ''),
'required': not getattr(field, 'blank', True),
'type': 'select',
'dynamic_choices': True,
'choices_url': '/settings/integrations/openrouter/models/'
}
fields.append(field_info) fields.append(field_info)
# Для WooCommerce показываем только базовые поля для подключения # Для WooCommerce показываем только базовые поля для подключения
elif model.__name__ == 'WooCommerceIntegration': elif model.__name__ == 'WooCommerceIntegration':

View File

@@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
statusBadge.style.display = 'inline'; statusBadge.style.display = 'inline';
// Построить форму // Построить форму (теперь асинхронно)
buildForm(data.fields, data.data || {}); await buildForm(data.fields, data.data || {});
// Показать/скрыть кнопку тестирования // Показать/скрыть кнопку тестирования
const testBtn = document.getElementById('test-connection-btn'); const testBtn = document.getElementById('test-connection-btn');
@@ -173,11 +173,11 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Построение формы из метаданных полей // Построение формы из метаданных полей
function buildForm(fields, data) { async function buildForm(fields, data) {
const container = document.getElementById('settings-fields'); const container = document.getElementById('settings-fields');
container.innerHTML = ''; container.innerHTML = '';
fields.forEach(field => { for (const field of fields) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'mb-3'; div.className = 'mb-3';
@@ -189,27 +189,82 @@ document.addEventListener('DOMContentLoaded', function() {
<label class="form-check-label" for="field-${field.name}">${field.label}</label> <label class="form-check-label" for="field-${field.name}">${field.label}</label>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''} ${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`; `;
} else if (field.type === 'select') { } else if (field.type === 'select') {
div.innerHTML = ` let optionsHtml = '';
<label class="form-label" for="field-${field.name}">
${field.label} if (field.dynamic_choices) {
${field.required ? '<span class="text-danger">*</span>' : ''} // Динамическая загрузка options
</label> optionsHtml = '<option value="">Загрузка моделей...</option>';
<select class="form-select" id="field-${field.name}"
name="${field.name}" div.innerHTML = `
${field.required ? 'required' : ''}> <label class="form-label" for="field-${field.name}">
${field.choices.map(choice => ` ${field.label}
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}> ${field.required ? '<span class="text-danger">*</span>' : ''}
${choice[1]} </label>
</option> <select class="form-select" id="field-${field.name}"
`).join('')} name="${field.name}"
</select> ${field.required ? 'required' : ''}>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''} ${optionsHtml}
`; </select>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
container.appendChild(div);
// Асинхронная загрузка
const select = div.querySelector('select');
try {
const response = await fetch(field.choices_url);
const result = await response.json();
if (result.error) {
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
console.error(result.error);
} else {
select.innerHTML = result.models.map(m =>
`<option value="${m.id}">${m.name}</option>`
).join('');
if (data[field.name]) {
select.value = data[field.name];
}
}
} catch (error) {
select.innerHTML = '<option value="">Ошибка загрузки моделей</option>';
console.error('Error loading models:', error);
}
} else {
// Статический select (для temperature)
optionsHtml = field.choices.map(choice => `
<option value="${choice[0]}" ${data[field.name] === choice[0] ? 'selected' : ''}>
${choice[1]}
</option>
`).join('');
div.innerHTML = `
<label class="form-label" for="field-${field.name}">
${field.label}
${field.required ? '<span class="text-danger">*</span>' : ''}
</label>
<select class="form-select" id="field-${field.name}"
name="${field.name}"
${field.required ? 'required' : ''}>
${optionsHtml}
</select>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`;
}
} else { } else {
// text, password, url
const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text'); const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text');
const value = data[field.name] || ''; let value = data[field.name] || '';
const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : ''; const isMasked = value === '••••••••';
const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : '';
// Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа)
const inputValue = (field.type === 'password' && isMasked) ? '********' : value;
div.innerHTML = ` div.innerHTML = `
<label class="form-label" for="field-${field.name}"> <label class="form-label" for="field-${field.name}">
@@ -217,15 +272,17 @@ document.addEventListener('DOMContentLoaded', function() {
${field.required ? '<span class="text-danger">*</span>' : ''} ${field.required ? '<span class="text-danger">*</span>' : ''}
</label> </label>
<input type="${inputType}" class="form-control" id="field-${field.name}" <input type="${inputType}" class="form-control" id="field-${field.name}"
name="${field.name}" value="${value !== '........' ? value : ''}" name="${field.name}" value="${inputValue}"
placeholder="${placeholder}" placeholder="${placeholder}"
${field.required ? 'required' : ''}> ${field.required && !isMasked ? 'required' : ''}>
${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''} ${field.help_text ? `<div class="form-text">${field.help_text}</div>` : ''}
`; `;
} }
container.appendChild(div); if (field.type !== 'select' || !field.dynamic_choices) {
}); container.appendChild(div);
}
}
} }
// Обработчик клика на интеграцию // Обработчик клика на интеграцию
@@ -313,9 +370,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Собрать данные формы // Собрать данные формы
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
// Пропустить пустые password поля (не менять если не введено) // Пропустить пустые password поля или звёздочки (не менять если не введено новое значение)
const input = document.getElementById(`field-${key}`); const input = document.getElementById(`field-${key}`);
if (input && input.type === 'password' && !value) continue; if (input && input.type === 'password' && (!value || value === '********')) continue;
data[key] = value; data[key] = value;
} }