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 @@
"remove_provider_title": "Remove Provider",
"remove_model_confirm": "Remove this 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": {
"model_name": "Model Name",
......
......@@ -60,6 +60,9 @@ const rotationsData = {
};
let rotationsConfig = rotationsData.config;
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 }};
let expandedRotations = new Set();
let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {});
......@@ -250,14 +253,18 @@ function msOpenProviderForInput(btn) {
// Provider select widget: <select> for 25, datalist input for >25
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) {
const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
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 {
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;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}"
......@@ -270,7 +277,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
}
function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') {
if (availableProviders.includes(value) || META_PROVIDERS.includes(value) || value === '') {
callback(value);
}
}
......@@ -482,6 +489,24 @@ function renderRotationModels(rotationKey, providerIndex) {
const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
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 = `
<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>
......@@ -489,18 +514,15 @@ function renderRotationModels(rotationKey, providerIndex) {
</div>
<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;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="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;">
<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>
${nameTrailing}
</div>
</div>
......
......@@ -53,6 +53,9 @@ const rotationsData = {
};
let rotationsConfig = rotationsData.config;
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 }};
let expandedRotations = new Set();
let rotationMasterOrder = Object.keys(rotationsConfig.rotations || {});
......@@ -242,14 +245,18 @@ function msOpenProviderForInput(btn) {
}
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) {
const opts = availableProviders.map(p =>
`<option value="${escHtmlAttr(p)}" ${currentValue === p ? 'selected' : ''}>${escHtmlAttr(p)}</option>`
).join('');
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 {
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;">
<input type="text" id="${uid}" value="${escHtmlAttr(currentValue)}" list="${uid}-dl"
placeholder="${window.i18n.t('rotations.type_search_provider')}"
......@@ -262,7 +269,7 @@ function buildProviderSelectHtml(uid, currentValue, onChangeFn) {
}
function handleProviderInput(uid, value, callback) {
if (availableProviders.includes(value) || value === '') {
if (availableProviders.includes(value) || META_PROVIDERS.includes(value) || value === '') {
callback(value);
}
}
......@@ -505,6 +512,24 @@ function renderRotationModels(rotationKey, providerIndex) {
const inputId = `mdl-inp-${rotationKey}-${providerIndex}-${modelIndex}`;
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 = `
<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>
......@@ -512,18 +537,15 @@ function renderRotationModels(rotationKey, providerIndex) {
</div>
<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;">
<input type="text" id="${escHtmlAttr(inputId)}" value="${escHtmlAttr(model.name)}"
${isMeta ? `list="${escHtmlAttr(metaDlId)}"` : ''}
oninput="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;">
<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>
${nameTrailing}
</div>
</div>
......
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