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:
@@ -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='Название модели'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user