Rotation entries can forward to a rotation/autoselect, with loop protection

A rotation provider entry whose provider_id is "rotations" or "autoselect" is a
meta-provider: each model "name" is the id of a target rotation/autoselect, and
the request is forwarded there (weighted like any other entry, with failover to
the next entry on total failure).

- handlers.py: detect meta-providers in model-building, delegate in the retry
  loop, and resolve rotation vs autoselect targets.
- Loop protection: a delegation chain rides on request_data and is registered by
  both RotationHandler and AutoselectHandler; re-entering an id already in the
  chain (A->B->A) or exceeding depth 8 raises HTTP 508 (caught upstream -> fail
  over). Loops are also pre-filtered at model-build time.
- Dashboard: the provider select in rotations.html / user_rotations.html now
  offers "rotations"/"autoselect"; the model row becomes a target picker.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 0a3a1df7
This diff is collapsed.
...@@ -337,7 +337,10 @@ ...@@ -337,7 +337,10 @@
"remove_provider_title": "Remove Provider", "remove_provider_title": "Remove Provider",
"remove_model_confirm": "Remove this model?", "remove_model_confirm": "Remove this model?",
"remove_model_title": "Remove Model", "remove_model_title": "Remove Model",
"error_saving": "Error saving configuration" "error_saving": "Error saving configuration",
"forward_to": "Forward to",
"target_name": "Target rotation / autoselect",
"target_placeholder": "rotation or autoselect name…"
}, },
"autoselect": { "autoselect": {
"model_name": "Model Name", "model_name": "Model Name",
......
...@@ -60,6 +60,9 @@ const rotationsData = { ...@@ -60,6 +60,9 @@ const rotationsData = {
}; };
let rotationsConfig = rotationsData.config; let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers; let availableProviders = rotationsData.providers;
// Meta-providers: a rotation entry can forward to a named rotation/autoselect
// instead of a real provider. The model "name" then holds the target id.
const META_PROVIDERS = ['rotations', 'autoselect'];
const providersMeta = {{ providers_meta | default('{}') | safe }}; const providersMeta = {{ providers_meta | default('{}') | safe }};
let expandedRotations = new Set(); let expandedRotations = new Set();
let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {}); let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {});
...@@ -250,14 +253,18 @@ function msOpenProviderForInput(btn) { ...@@ -250,14 +253,18 @@ function msOpenProviderForInput(btn) {
// Provider select widget: <select> for 25, datalist input for >25 // Provider select widget: <select> for 25, datalist input for >25
function buildProviderSelectHtml(uid, currentValue, onChangeFn) { function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
const metaOpts = META_PROVIDERS.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
if (availableProviders.length <= 25) { if (availableProviders.length <= 25) {
const opts = availableProviders.map(p => const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>` `<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join(''); ).join('');
return `<select id="${uid}" onchange="(${onChangeFn})(this.value)" required> return `<select id="${uid}" onchange="(${onChangeFn})(this.value)" required>
<option value="">${window.i18n.t('rotations.select_provider')}</option>${opts}</select>`; <option value="">${window.i18n.t('rotations.select_provider')}</option>
<optgroup label="${window.i18n.t('rotations.forward_to') || 'Forward to'}">${metaOpts}</optgroup>${opts}</select>`;
} else { } else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join(''); const dlOpts = (META_PROVIDERS.concat(availableProviders)).map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;"> return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl" <input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}" placeholder="${window.i18n.t('rotations.type_search_provider')}"
...@@ -270,7 +277,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) { ...@@ -270,7 +277,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
} }
function handleProviderInput(uid, value, callback) { function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') { if (availableProviders.includes(value) || META_PROVIDERS.includes(value) || value === '') {
callback(value); callback(value);
} }
} }
...@@ -482,6 +489,24 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -482,6 +489,24 @@ function renderRotationModels(rotationKey, providerIndex) {
const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`; const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || ''; const providerId = provider.provider_id || '';
// Meta-providers forward to a named rotation/autoselect: the "model name"
// is the target id, so swap the model-search button for a datalist of
// known rotation names and relabel the field.
const isMeta = META_PROVIDERS.includes(providerId);
const metaDlId = `mdl-meta-dl-${rotationKey}-${providerIndex}-${modelIndex}`;
const metaDatalist = isMeta
? `<datalist id="${escHtmlAttr(metaDlId)}">${Object.keys(rotationsConfig.rotations || {}).map(rn => `<option value="${escHtmlAttr(rn)}">`).join('')}</datalist>`
: '';
const nameLabel = isMeta ? (window.i18n.t('rotations.target_name') || 'Target rotation/autoselect') : window.i18n.t('rotations.model_name');
const namePlaceholder = isMeta ? (window.i18n.t('rotations.target_placeholder') || 'rotation or autoselect name…') : 'Model name…';
const nameTrailing = isMeta
? metaDatalist
: `<button type="button" class="btn btn-secondary"
data-provider="${escHtmlAttr(providerId)}"
data-input-id="${escHtmlAttr(inputId)}"
onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>`;
modelDiv.innerHTML = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">${window.i18n.t('rotations.model_label')} ${modelIndex + 1}</strong> <strong style="font-size: 13px;">${window.i18n.t('rotations.model_label')} ${modelIndex + 1}</strong>
...@@ -489,21 +514,18 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -489,21 +514,18 @@ function renderRotationModels(rotationKey, providerIndex) {
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.model_name')}</label> <label style="font-size: 12px;">${nameLabel}</label>
<div style="display:flex;gap:5px;align-items:center;"> <div style="display:flex;gap:5px;align-items:center;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}" <input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)" oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)" onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
placeholder="Model name…" placeholder="${escHtmlAttr(namePlaceholder)}"
required style="flex:1;font-size:12px;padding:5px;"> required style="flex:1;font-size:12px;padding:5px;">
<button type="button" class="btn btn-secondary" ${nameTrailing}
data-provider="${escHtmlAttr(providerId)}"
data-input-id="${escHtmlAttr(inputId)}"
onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>
</div> </div>
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label>
<input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;"> <input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;">
......
...@@ -53,6 +53,9 @@ const rotationsData = { ...@@ -53,6 +53,9 @@ const rotationsData = {
}; };
let rotationsConfig = rotationsData.config; let rotationsConfig = rotationsData.config;
let availableProviders = rotationsData.providers; let availableProviders = rotationsData.providers;
// Meta-providers: a rotation entry can forward to a named rotation/autoselect
// instead of a real provider. The model "name" then holds the target id.
const META_PROVIDERS = ['rotations', 'autoselect'];
const providersMeta = {{ providers_meta | default('{}') | safe }}; const providersMeta = {{ providers_meta | default('{}') | safe }};
let expandedRotations = new Set(); let expandedRotations = new Set();
let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {}); let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {});
...@@ -242,14 +245,18 @@ function msOpenProviderForInput(btn) { ...@@ -242,14 +245,18 @@ function msOpenProviderForInput(btn) {
} }
function buildProviderSelectHtml(uid, currentValue, onChangeFn) { function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
const metaOpts = META_PROVIDERS.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
if (availableProviders.length <= 25) { if (availableProviders.length <= 25) {
const opts = availableProviders.map(p => const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>` `<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join(''); ).join('');
return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required> return `<select id="${uid}" onchange="${onChangeFn}(this.value)" required>
<option value="">${window.i18n.t('rotations.select_provider')}</option>${opts}</select>`; <option value="">${window.i18n.t('rotations.select_provider')}</option>
<optgroup label="${window.i18n.t('rotations.forward_to') || 'Forward to'}">${metaOpts}</optgroup>${opts}</select>`;
} else { } else {
const dlOpts = availableProviders.map(p => `<option value="${escHtmlAttr(p)}">`).join(''); const dlOpts = (META_PROVIDERS.concat(availableProviders)).map(p => `<option value="${escHtmlAttr(p)}">`).join('');
return `<div style="position:relative;"> return `<div style="position:relative;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl" <input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}" placeholder="${window.i18n.t('rotations.type_search_provider')}"
...@@ -262,7 +269,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) { ...@@ -262,7 +269,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
} }
function handleProviderInput(uid, value, callback) { function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') { if (availableProviders.includes(value) || META_PROVIDERS.includes(value) || value === '') {
callback(value); callback(value);
} }
} }
...@@ -505,6 +512,24 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -505,6 +512,24 @@ function renderRotationModels(rotationKey, providerIndex) {
const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`; const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
const providerId = provider.provider_id || ''; const providerId = provider.provider_id || '';
// Meta-providers forward to a named rotation/autoselect: the "model name"
// is the target id, so swap the model-search button for a datalist of
// known rotation names and relabel the field.
const isMeta = META_PROVIDERS.includes(providerId);
const metaDlId = `mdl-meta-dl-${rotationKey}-${providerIndex}-${modelIndex}`;
const metaDatalist = isMeta
? `<datalist id="${escHtmlAttr(metaDlId)}">${Object.keys(rotationsConfig.rotations || {}).map(rn => `<option value="${escHtmlAttr(rn)}">`).join('')}</datalist>`
: '';
const nameLabel = isMeta ? (window.i18n.t('rotations.target_name') || 'Target rotation/autoselect') : window.i18n.t('rotations.model_name');
const namePlaceholder = isMeta ? (window.i18n.t('rotations.target_placeholder') || 'rotation or autoselect name…') : 'Model name…';
const nameTrailing = isMeta
? metaDatalist
: `<button type="button" class="btn btn-secondary"
data-provider="${escHtmlAttr(providerId)}"
data-input-id="${escHtmlAttr(inputId)}"
onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>`;
modelDiv.innerHTML = ` modelDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="font-size: 13px;">${window.i18n.t('rotations.model_label')} ${modelIndex + 1}</strong> <strong style="font-size: 13px;">${window.i18n.t('rotations.model_label')} ${modelIndex + 1}</strong>
...@@ -512,21 +537,18 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -512,21 +537,18 @@ function renderRotationModels(rotationKey, providerIndex) {
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.model_name')}</label> <label style="font-size: 12px;">${nameLabel}</label>
<div style="display:flex;gap:5px;align-items:center;"> <div style="display:flex;gap:5px;align-items:center;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}" <input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)" oninput="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)" onchange="updateRotationModel('${safeKey}',${providerIndex},${modelIndex},'name',this.value)"
placeholder="Model name…" placeholder="${escHtmlAttr(namePlaceholder)}"
required style="flex:1;font-size:12px;padding:5px;"> required style="flex:1;font-size:12px;padding:5px;">
<button type="button" class="btn btn-secondary" ${nameTrailing}
data-provider="${escHtmlAttr(providerId)}"
data-input-id="${escHtmlAttr(inputId)}"
onclick="msOpenProviderForInput(this)"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">${window.i18n.t('rotations.search')}</button>
</div> </div>
</div> </div>
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label> <label style="font-size: 12px;">${window.i18n.t('rotations.weight')}</label>
<input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;"> <input type="number" value="${model.weight || 1}" onchange="updateRotationModel('${rotationKey}', ${providerIndex}, ${modelIndex}, 'weight', parseInt(this.value))" style="font-size: 12px; padding: 5px;">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment