Spaces:
Running
Running
Anurag commited on
Commit Β·
9f5cc78
1
Parent(s): 72d696f
Fix custom env builder card layout
Browse files- env-builder.html +12 -2
- env-builder.js +15 -1
- key-rotator-manager.html +8 -8
- multi-provider-key-rotator.cjs +138 -16
env-builder.html
CHANGED
|
@@ -788,11 +788,20 @@ body {
|
|
| 788 |
line-height: 1.45;
|
| 789 |
}
|
| 790 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
.custom-env-card .card-top { margin-bottom: 12px; }
|
| 792 |
|
| 793 |
.custom-card-grid {
|
| 794 |
display: grid;
|
| 795 |
-
grid-template-columns: minmax(
|
| 796 |
gap: 8px;
|
| 797 |
align-items: end;
|
| 798 |
}
|
|
@@ -840,7 +849,8 @@ body {
|
|
| 840 |
color: var(--red);
|
| 841 |
}
|
| 842 |
|
| 843 |
-
@media (max-width:
|
|
|
|
| 844 |
.custom-card-grid { grid-template-columns: 1fr; }
|
| 845 |
.custom-remove { width: 100%; }
|
| 846 |
}
|
|
|
|
| 788 |
line-height: 1.45;
|
| 789 |
}
|
| 790 |
|
| 791 |
+
#customRows.cards {
|
| 792 |
+
grid-template-columns: 1fr;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.custom-env-card {
|
| 796 |
+
width: 100%;
|
| 797 |
+
max-width: none;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
.custom-env-card .card-top { margin-bottom: 12px; }
|
| 801 |
|
| 802 |
.custom-card-grid {
|
| 803 |
display: grid;
|
| 804 |
+
grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.4fr) minmax(120px, .65fr) max-content;
|
| 805 |
gap: 8px;
|
| 806 |
align-items: end;
|
| 807 |
}
|
|
|
|
| 849 |
color: var(--red);
|
| 850 |
}
|
| 851 |
|
| 852 |
+
@media (max-width: 720px) {
|
| 853 |
+
.custom-env-toolbar { align-items: stretch; flex-direction: column; }
|
| 854 |
.custom-card-grid { grid-template-columns: 1fr; }
|
| 855 |
.custom-remove { width: 100%; }
|
| 856 |
}
|
env-builder.js
CHANGED
|
@@ -2587,7 +2587,21 @@ function addCustomRow(key = '', val = '', enabled = false, tag = 'optional') {
|
|
| 2587 |
updateCounts();
|
| 2588 |
});
|
| 2589 |
row.querySelector(`[data-custom-remove="${id}"]`)?.addEventListener('click', () => {
|
| 2590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2591 |
refresh();
|
| 2592 |
filter();
|
| 2593 |
});
|
|
|
|
| 2587 |
updateCounts();
|
| 2588 |
});
|
| 2589 |
row.querySelector(`[data-custom-remove="${id}"]`)?.addEventListener('click', () => {
|
| 2590 |
+
const remaining = document.querySelectorAll('[data-custom-row]').length;
|
| 2591 |
+
if (remaining <= 1) {
|
| 2592 |
+
const keyInput = row.querySelector(`[data-ck="${id}"]`);
|
| 2593 |
+
const valueInput = row.querySelector(`[data-cv="${id}"]`);
|
| 2594 |
+
const tagInput = row.querySelector(`[data-ct="${id}"]`);
|
| 2595 |
+
if (keyInput) keyInput.value = '';
|
| 2596 |
+
if (valueInput) valueInput.value = '';
|
| 2597 |
+
if (tagInput) tagInput.value = 'optional';
|
| 2598 |
+
if (enabledInput) enabledInput.checked = false;
|
| 2599 |
+
row.dataset.enabled = '0';
|
| 2600 |
+
row.classList.remove('selected');
|
| 2601 |
+
updateCustomRowMeta(row);
|
| 2602 |
+
} else {
|
| 2603 |
+
row.remove();
|
| 2604 |
+
}
|
| 2605 |
refresh();
|
| 2606 |
filter();
|
| 2607 |
});
|
key-rotator-manager.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
<title>HuggingClaw Β· API Key Rotator</title>
|
| 7 |
<style>
|
| 8 |
-
:root{color-scheme:dark;--bg:#070711;--panel:#111120;--panel2:#17172a;--line:#292945;--text:#f8f7ff;--muted:#9892b8;--soft:#c7c2e6;--good:#22c55e;--warn:#f5c542;--bad:#fb7185;--blue:#60a5fa;--violet:#a78bfa}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#25145a 0,#070711 34%,#070711 100%);font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--text);font-size:13px}main{width:min(1280px,calc(100% - 28px));margin:0 auto;padding:28px 0 44px}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:18px}.eyebrow{font-size:.7rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:900}h1{margin:6px 0 8px;font-size:clamp(1.6rem,4vw,2.7rem);line-height:1}.sub{color:var(--soft);max-width:860px;line-height:1.55}.actions{display:flex;gap:10px;flex-wrap:wrap}.btn{border:1px solid var(--line);background:rgba(255,255,255,.06);color:var(--text);border-radius:11px;padding:10px 14px;text-decoration:none;font-weight:850;cursor:pointer}.btn.primary{background:#fff;color:#050510}.btn:hover{filter:brightness(1.08)}.grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:18px 0}.card{background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.025));border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 18px 45px rgba(0,0,0,.22)}.metric-title{color:var(--muted);text-transform:uppercase;letter-spacing:.16em;font-size:.66rem;font-weight:900}.metric-value{font-size:1.65rem;font-weight:950;margin-top:8px}.metric-detail{color:var(--muted);margin-top:6px;line-height:1.45}.ok{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.blue{color:var(--blue)}.layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:14px}.panel-title{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.panel-title h2{font-size:1rem;margin:0}.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:5px 9px;color:var(--soft);background:rgba(255,255,255,.035);font-size:.72rem;font-weight:850}.dot{width:7px;height:7px;border-radius:50%;background:var(--muted)}.dot.live{background:var(--good);box-shadow:0 0 15px var(--good)}.providers{display:flex;flex-direction:column;gap:9px}.provider{border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.12);padding:12px;cursor:pointer}.provider.active{border-color:var(--blue);box-shadow:0 0 0 1px rgba(96,165,250,.25)}.provider-top{display:flex;justify-content:space-between;align-items:center;gap:12px}.provider-name{font-weight:950}.provider-meta{color:var(--muted);font-size:.78rem;margin-top:5px}.bar{height:8px;background:#22223a;border-radius:999px;overflow:hidden;margin-top:10px}.bar>span{display:block;height:100%;background:linear-gradient(90deg,var(--violet),var(--blue));border-radius:999px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.input{flex:1;min-width:180px;border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px;outline:none}.select{border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px}.toggle{display:flex;align-items:center;gap:7px;color:var(--soft);font-weight:800}.events{display:flex;flex-direction:column;gap:9px;max-height:620px;overflow:auto;padding-right:4px}.event{border:1px solid var(--line);border-left-width:4px;border-radius:14px;background:rgba(0,0,0,.16);padding:12px;display:grid;grid-template-columns:155px minmax(0,1fr) auto;gap:12px}.event.pick,.event.sticky_pick,.event.pick_retry_fresh,.event.model_detected{border-left-color:var(--blue)}.event.success{border-left-color:var(--good)}.event.rate_limited,.event.auth_failed,.event.transport_aborted,.event.all_suspended_pick,.event.all_suspended_withheld{border-left-color:var(--warn)}.event.network_retryable,.event.transient_status,.event.saturated_reuse,.event.sticky_saturated_reuse,.event.sticky_saturated_rotate
|
| 9 |
</style>
|
| 10 |
</head>
|
| 11 |
<body>
|
|
@@ -62,15 +62,15 @@ function eventClass(v){return String(v||'event').replace(/[^a-z0-9_-]/gi,'_');}
|
|
| 62 |
function fmtTime(ts){const d=new Date(ts);return isNaN(d)?'β':d.toLocaleString();}
|
| 63 |
function isKeyEvent(e){return e.provider&&e.slot&&(e.key||e.type==='all_suspended_withheld');}
|
| 64 |
function keyId(provider,slot,key){return `${provider}::${slot||''}::${key||''}`;}
|
| 65 |
-
function eventText(e){const bits=[];if(e.provider)bits.push(e.provider);if(e.slot)bits.push(`#${e.slot}/${e.total}`);if(e.key)bits.push(e.key);if(e.model)bits.push(`model=${e.model}`);if(e.status)bits.push(`status=${e.status}`);if(e.errorStatus)bits.push(`error=${e.errorStatus}`);if(e.errorReason)bits.push(`reason=${e.errorReason}`);if(e.errorType)bits.push(`type=${e.errorType}`);if(e.waitMs)bits.push(`wait=${Math.round(e.waitMs/1000)}s`);if(e.code)bits.push(`code=${e.code}`);if(e.errorCode)bits.push(`errorCode=${e.errorCode}`);if(e.inflight)bits.push(`inflight=${e.inflight}/${e.maxInflight||'?'}`);return bits.join(' Β· ');}
|
| 66 |
-
function emptyStat(provider,slot,total,key){return{provider,slot,total,key,picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0,last:'',lastPick:'',models:new Map()};}
|
| 67 |
-
function buildStats(){const stats=new Map();for(const p of state.providers){for(const k of (p.keys||[]))stats.set(keyId(p.name,k.slot,k.key),emptyStat(p.name,k.slot,k.total,k.key));}for(const e of state.events){if(!isKeyEvent(e))continue;const id=keyId(e.provider,e.slot,e.key);if(!stats.has(id))stats.set(id,emptyStat(e.provider,e.slot,e.total,e.key||'***'));const s=stats.get(id);const isPick=PICK_TYPES.includes(e.type);const isOutcome=['success','rate_limited','network_retryable','auth_failed','transient_status','
|
| 68 |
-
function ageStalePending(v,now){const pending=pendingCount(v);if(!pending)return;const ts=Date.parse(v.lastPick||v.last||'');if(Number.isFinite(ts)&&now-ts>STALE_PENDING_MS){v.
|
| 69 |
function providerRows(provider,stats){return (provider?.keys||[]).map(k=>stats.get(keyId(provider.name,k.slot,k.key))||emptyStat(provider.name,k.slot,k.total,k.key));}
|
| 70 |
-
function pendingCount(v){return Math.max(0,(v.picks||0)-((v.success||0)+(v.rate||0)+(v.retry||0)+(v.transient||0)+(v.auth||0)+(v.abort||0)));}
|
| 71 |
-
function modelChips(row){const entries=[...row.models.entries()];const un=row.unscoped||{picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0};const unPending=pendingCount(un);if(!entries.length){const total=row.picks+row.success+row.rate+row.retry+row.transient+row.auth+(row.abort||0);return total?`<span class="model-chip">unscoped Β· p:${row.picks} ok:${row.success} pending:${pendingCount(row)} rl:${row.rate} retry:${row.retry+row.transient}${row.abort?` abort:${row.abort}`:''}</span>`:'<span class="model-chip">no model events yet</span>';}const chips=entries.map(([m,v])=>{const onlyObserved=v.observed&&!v.picks&&!v.success&&!v.rate&&!v.retry&&!v.transient&&!v.auth&&!v.abort;return `<span class="model-chip">${esc(m)} Β· ${onlyObserved?'observed':`p:${v.picks} ok:${v.success} pending:${pendingCount(v)} rl:${v.rate} retry:${v.retry+v.transient}${v.abort?` abort:${v.abort}`:''}`}</span>`;});if(un.picks||un.success||un.rate||un.retry||un.transient||un.auth||un.abort)chips.push(`<span class="model-chip">unscoped totals Β· p:${un.picks} ok:${un.success} pending:${unPending} rl:${un.rate} retry:${un.retry+un.transient}${un.abort?` abort:${un.abort}`:''}</span>`);return chips.join('');}
|
| 72 |
function routeNote(provider){const routes=(state.runtime&&state.runtime.routes)||[],hits=routes.filter(r=>r.provider===provider.name);if(!routes.length)return '<div class="session-note">Current LLM route is unknown to this view. Usage appears only after OpenClaw sends a request to this provider hostname.</div>';if(hits.length)return `<div class="session-note"><b>Active route:</b> ${hits.map(r=>`${esc(r.role)} ${esc(r.model)}`).join(' Β· ')}. New messages using this route should create pick/success events below.</div>`;return `<div class="session-note bad"><b>Not on current model route.</b> Current route is ${routes.map(r=>`${esc(r.role)} ${esc(r.model)} β ${esc(r.provider)}`).join(' Β· ')}. ${esc(provider.name)} keys stay unused until you select a ${esc(provider.name)} model or a fallback reaches it.</div>`;}
|
| 73 |
-
function renderProviderDetails(provider,stats){if(!provider){$('detailTitle').textContent='Provider details';$('details').innerHTML='<div class="empty">Select a provider to see key/session usage.</div>';return;}const rows=providerRows(provider,stats),used=rows.filter(r=>r.picks||r.success).length,rates=rows.reduce((a,r)=>a+r.rate,0),retries=rows.reduce((a,r)=>a+r.retry+r.transient,0),auths=rows.reduce((a,r)=>a+r.auth,0),aborts=rows.reduce((a,r)=>a+(r.abort||0),0),pending=rows.reduce((a,r)=>a+pendingCount(r),0);$('detailTitle').textContent=`${provider.name} Β· provider session`;const modelSet=new Set();rows.forEach(r=>r.models.forEach((_,m)=>modelSet.add(m)));const table=rows.map(r=>{const usedKey=(r.picks||r.success)>0,pending=pendingCount(r);return `<tr><td><b class="mono">#${r.slot}/${r.total}</b><div class="key">${esc(r.key)}</div></td><td><span class="status ${usedKey?'used':'unused'}">${usedKey?'USED':'UNUSED'}</span><div class="metric-detail">last: ${esc(r.last?fmtTime(r.last):'never')}</div>${pending?`<div class="metric-detail warn">pending/no response observed: ${pending}</div>`:''}</td><td>pick ${r.picks}<br>success ${r.success}<br>pending ${pending}<br>rate ${r.rate}</td><td>retry ${r.retry}<br>transient ${r.transient}<br>auth ${r.auth}<br>abort ${r.abort||0}</td><td>${modelChips(r)}</td></tr>`;}).join('');$('details').innerHTML=`${routeNote(provider)}<div class="session-note">Session: <b>${esc(provider.name)}</b>. Raw keys are never returned here; this page only shows masked keys and slot numbers from the configured pool.</div><div class="detail-grid"><div class="mini"><span class="metric-title">Total keys</span><b>${provider.total||0}</b></div><div class="mini"><span class="metric-title">Used / unused</span><b>${used}/${Math.max(0,(provider.total||0)-used)}</b></div><div class="mini"><span class="metric-title">Rate / pending</span><b>${rates}/${pending}</b></div><div class="mini"><span class="metric-title">Retry/auth/abort</span><b>${retries}/${auths}/${aborts}</b></div></div>${provider.name==='gemini'?`<p class="foot">Gemini quota is tracked per key + model. Models seen: ${modelSet.size?[...modelSet].map(m=>`<span class="model-chip">${esc(m)}</span>`).join(''):'<span class="kbd">none yet β new requests will show per-model once the rotator can read the request body</span>'}</p>`:''}<div class="key-table-wrap"><table class="key-table"><thead><tr><th>Hidden key slot</th><th>Status</th><th>Usage</th><th>Failures / aborts</th><th>Per-model / details</th></tr></thead><tbody>${table||'<tr><td colspan="5">No keys configured</td></tr>'}</tbody></table></div>`;}
|
| 74 |
function render(){const q=$('q').value.trim().toLowerCase(),type=$('type').value,providers=state.providers||[],events=state.events||[],stats=buildStats();if(!providers.some(p=>p.name===state.selected)&&providers[0])state.selected=providers[0].name;const totalKeys=providers.reduce((a,p)=>a+(Number(p.total)||0),0),usedKeyCount=[...stats.values()].filter(s=>s.picks||s.success).length;$('mKeys').textContent=totalKeys;$('mUsed').textContent=usedKeyCount;$('mUnused').textContent=Math.max(0,totalKeys-usedKeyCount);$('mRate').textContent=events.filter(e=>e.type==='rate_limited').length;$('mUpdate').textContent=new Date().toLocaleTimeString();$('providerCount').textContent=`${providers.length} active`;$('eventCount').textContent=`${events.length} events`;$('providers').innerHTML=providers.length?providers.map(p=>{const rows=providerRows(p,stats),used=rows.filter(r=>r.picks||r.success).length,total=Number(p.total)||0,aliases=p.aliases?' Β· '+esc(p.aliases):'';return `<button class="provider ${p.name===state.selected?'active':''}" data-provider="${esc(p.name)}" type="button"><div class="provider-top"><div class="provider-name">${esc(p.name)}</div><span class="pill">${used}/${total} used</span></div><div class="provider-meta">${esc(p.env||'configured pool')}${aliases}</div><div class="bar"><span style="width:${total?Math.round((used/total)*100):0}%"></span></div></button>`;}).join(''):'<div class="empty">No key pools detected in this process env.</div>';$('providers').querySelectorAll('[data-provider]').forEach(el=>el.onclick=()=>{state.selected=el.dataset.provider;localStorage.setItem('hc.keyRotator.provider',state.selected);render();});renderProviderDetails(providers.find(p=>p.name===state.selected)||providers[0],stats);let filtered=events.slice().reverse().filter(e=>(!type||e.type===type));if($('scope').checked&&state.selected)filtered=filtered.filter(e=>!e.provider||e.provider==='system'||e.provider===state.selected);if(q)filtered=filtered.filter(e=>JSON.stringify(e).toLowerCase().includes(q));$('events').innerHTML=filtered.length?filtered.map(e=>`<article class="event ${eventClass(e.type)}"><div class="time">${esc(fmtTime(e.ts))}</div><div><div class="etype">${esc(e.type||'event')}</div><div class="msg">${esc(eventText(e)||'system event')}</div></div><div class="key">${esc(e.key||'')}</div></article>`).join(''):'<div class="empty">No matching events yet. Send a message or disable provider scope to view other providers.</div>';}
|
| 75 |
async function load(){if(state.paused)return;try{const r=await fetch('/api/key-rotator/logs?limit=1000',{cache:'no-store'});if(r.status===401){location.href='/login?next='+encodeURIComponent('/key-rotator');return;}const d=await r.json();state.events=d.events||[];state.providers=d.providers||[];state.runtime=d.runtime||{routes:[]};state.lastPayload=d;const log=d.log||{};$('source').textContent=log.exists?`${d.file||'event log'} Β· ${Math.round((log.size||0)/1024)} KiB`:`${d.file||'event log'} Β· waiting`;render();}catch(e){$('events').innerHTML='<div class="empty bad">Could not load rotator logs: '+esc(e.message)+'</div>';}}
|
| 76 |
$('q').addEventListener('input',render);$('type').addEventListener('change',render);$('scope').addEventListener('change',render);$('refresh').onclick=load;$('pause').onclick=()=>{state.paused=!state.paused;$('pause').textContent=state.paused?'Resume':'Pause';$('liveDot').classList.toggle('live',!state.paused);$('liveText').textContent=state.paused?'Paused':'Live';};$('export').onclick=()=>{const data=JSON.stringify(state.lastPayload||{providers:state.providers,events:state.events},null,2),blob=new Blob([data],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='huggingclaw-key-rotator.json';a.click();URL.revokeObjectURL(url);};load();setInterval(load,2500);
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
<title>HuggingClaw Β· API Key Rotator</title>
|
| 7 |
<style>
|
| 8 |
+
:root{color-scheme:dark;--bg:#070711;--panel:#111120;--panel2:#17172a;--line:#292945;--text:#f8f7ff;--muted:#9892b8;--soft:#c7c2e6;--good:#22c55e;--warn:#f5c542;--bad:#fb7185;--blue:#60a5fa;--violet:#a78bfa}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#25145a 0,#070711 34%,#070711 100%);font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--text);font-size:13px}main{width:min(1280px,calc(100% - 28px));margin:0 auto;padding:28px 0 44px}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:18px}.eyebrow{font-size:.7rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:900}h1{margin:6px 0 8px;font-size:clamp(1.6rem,4vw,2.7rem);line-height:1}.sub{color:var(--soft);max-width:860px;line-height:1.55}.actions{display:flex;gap:10px;flex-wrap:wrap}.btn{border:1px solid var(--line);background:rgba(255,255,255,.06);color:var(--text);border-radius:11px;padding:10px 14px;text-decoration:none;font-weight:850;cursor:pointer}.btn.primary{background:#fff;color:#050510}.btn:hover{filter:brightness(1.08)}.grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:18px 0}.card{background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.025));border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 18px 45px rgba(0,0,0,.22)}.metric-title{color:var(--muted);text-transform:uppercase;letter-spacing:.16em;font-size:.66rem;font-weight:900}.metric-value{font-size:1.65rem;font-weight:950;margin-top:8px}.metric-detail{color:var(--muted);margin-top:6px;line-height:1.45}.ok{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.blue{color:var(--blue)}.layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:14px}.panel-title{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.panel-title h2{font-size:1rem;margin:0}.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:5px 9px;color:var(--soft);background:rgba(255,255,255,.035);font-size:.72rem;font-weight:850}.dot{width:7px;height:7px;border-radius:50%;background:var(--muted)}.dot.live{background:var(--good);box-shadow:0 0 15px var(--good)}.providers{display:flex;flex-direction:column;gap:9px}.provider{border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.12);padding:12px;cursor:pointer}.provider.active{border-color:var(--blue);box-shadow:0 0 0 1px rgba(96,165,250,.25)}.provider-top{display:flex;justify-content:space-between;align-items:center;gap:12px}.provider-name{font-weight:950}.provider-meta{color:var(--muted);font-size:.78rem;margin-top:5px}.bar{height:8px;background:#22223a;border-radius:999px;overflow:hidden;margin-top:10px}.bar>span{display:block;height:100%;background:linear-gradient(90deg,var(--violet),var(--blue));border-radius:999px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.input{flex:1;min-width:180px;border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px;outline:none}.select{border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px}.toggle{display:flex;align-items:center;gap:7px;color:var(--soft);font-weight:800}.events{display:flex;flex-direction:column;gap:9px;max-height:620px;overflow:auto;padding-right:4px}.event{border:1px solid var(--line);border-left-width:4px;border-radius:14px;background:rgba(0,0,0,.16);padding:12px;display:grid;grid-template-columns:155px minmax(0,1fr) auto;gap:12px}.event.pick,.event.sticky_pick,.event.pick_retry_fresh,.event.model_detected{border-left-color:var(--blue)}.event.success{border-left-color:var(--good)}.event.rate_limited,.event.auth_failed,.event.transport_aborted,.event.all_suspended_pick,.event.all_suspended_withheld,.event.client_error,.event.provider_error{border-left-color:var(--warn)}.event.network_retryable,.event.transient_status,.event.saturated_reuse,.event.sticky_saturated_reuse,.event.sticky_saturated_rotate{border-left-color:var(--bad)}.event.inflight_timeout,.event.inflight_lease_expired{border-left-color:var(--muted)}.time{color:var(--muted);font-variant-numeric:tabular-nums}.etype{font-weight:950}.msg{color:var(--soft);line-height:1.45;word-break:break-word}.key,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#e9d5ff}.empty{padding:34px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:16px}.foot{color:var(--muted);margin-top:16px;line-height:1.55}.kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#23233a;border:1px solid #363653;border-radius:7px;padding:2px 6px;color:var(--text)}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:12px}.mini{border:1px solid var(--line);border-radius:13px;padding:11px;background:rgba(0,0,0,.12)}.mini b{display:block;font-size:1.15rem;margin-top:4px}.key-table-wrap{overflow:auto}.key-table{width:100%;border-collapse:separate;border-spacing:0 8px;min-width:780px}.key-table th{color:var(--muted);font-size:.68rem;text-align:left;text-transform:uppercase;letter-spacing:.12em}.key-table td{background:rgba(0,0,0,.15);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px;vertical-align:top}.key-table td:first-child{border-left:1px solid var(--line);border-radius:12px 0 0 12px}.key-table td:last-child{border-right:1px solid var(--line);border-radius:0 12px 12px 0}.status{font-weight:900}.status.used{color:var(--good)}.status.unused{color:var(--muted)}.model-chip{display:inline-flex;margin:2px 4px 2px 0;padding:4px 7px;border:1px solid var(--line);border-radius:999px;color:var(--soft);background:rgba(255,255,255,.035);font-size:.72rem}.section{margin-top:14px}.session-note{border:1px solid rgba(96,165,250,.28);background:rgba(96,165,250,.08);border-radius:14px;padding:12px;color:var(--soft);margin-bottom:12px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1050px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.top{flex-direction:column}.event{grid-template-columns:1fr}.events{max-height:none}}@media(max-width:620px){.grid,.detail-grid{grid-template-columns:1fr}}
|
| 9 |
</style>
|
| 10 |
</head>
|
| 11 |
<body>
|
|
|
|
| 62 |
function fmtTime(ts){const d=new Date(ts);return isNaN(d)?'β':d.toLocaleString();}
|
| 63 |
function isKeyEvent(e){return e.provider&&e.slot&&(e.key||e.type==='all_suspended_withheld');}
|
| 64 |
function keyId(provider,slot,key){return `${provider}::${slot||''}::${key||''}`;}
|
| 65 |
+
function eventText(e){const bits=[];if(e.provider)bits.push(e.provider);if(e.slot)bits.push(`#${e.slot}/${e.total}`);if(e.key)bits.push(e.key);if(e.model)bits.push(`model=${e.model}`);if(e.status)bits.push(`status=${e.status}`);if(e.errorStatus)bits.push(`error=${e.errorStatus}`);if(e.errorReason)bits.push(`reason=${e.errorReason}`);if(e.errorType)bits.push(`type=${e.errorType}`);if(e.source)bits.push(`source=${e.source}`);if(e.name)bits.push(`name=${e.name}`);if(e.waitMs)bits.push(`wait=${Math.round(e.waitMs/1000)}s`);if(e.code)bits.push(`code=${e.code}`);if(e.errorCode)bits.push(`errorCode=${e.errorCode}`);if(e.message)bits.push(`message=${e.message}`);if(e.inflight)bits.push(`inflight=${e.inflight}/${e.maxInflight||'?'}`);return bits.join(' Β· ');}
|
| 66 |
+
function emptyStat(provider,slot,total,key){return{provider,slot,total,key,picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0,last:'',lastPick:'',models:new Map()};}
|
| 67 |
+
function buildStats(){const stats=new Map();for(const p of state.providers){for(const k of (p.keys||[]))stats.set(keyId(p.name,k.slot,k.key),emptyStat(p.name,k.slot,k.total,k.key));}for(const e of state.events){if(!isKeyEvent(e))continue;const id=keyId(e.provider,e.slot,e.key);if(!stats.has(id))stats.set(id,emptyStat(e.provider,e.slot,e.total,e.key||'***'));const s=stats.get(id);const isPick=PICK_TYPES.includes(e.type);const isOutcome=['success','rate_limited','network_retryable','auth_failed','transient_status','transport_aborted','client_error','provider_error'].includes(e.type),isLease=e.type==='inflight_timeout'||e.type==='inflight_lease_expired';if(isPick){s.picks++;s.lastPick=e.ts||s.lastPick;}if(e.type==='success')s.success++;if(e.type==='rate_limited')s.rate++;if(e.type==='network_retryable')s.retry++;if(e.type==='auth_failed')s.auth++;if(e.type==='transport_aborted')s.abort++;if(e.type==='client_error'||e.type==='provider_error')s.other++;if(isLease)s.lease++;if(e.type==='transient_status')s.transient++;s.last=e.ts||s.last;if(e.model){let m=s.models.get(e.model);if(!m)m={picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0,last:'',lastPick:'',observed:false};if(e.type==='model_detected')m.observed=true;if(isPick){m.picks++;m.lastPick=e.ts||m.lastPick;}if(e.type==='success')m.success++;if(e.type==='rate_limited')m.rate++;if(e.type==='network_retryable')m.retry++;if(e.type==='auth_failed')m.auth++;if(e.type==='transport_aborted')m.abort++;if(e.type==='client_error'||e.type==='provider_error')m.other++;if(isLease)m.lease++;if(e.type==='transient_status')m.transient++;m.last=e.ts||m.last;s.models.set(e.model,m);}else if(isPick||isOutcome){s.unscoped=s.unscoped||{picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0,last:'',lastPick:''};if(isPick){s.unscoped.picks++;s.unscoped.lastPick=e.ts||s.unscoped.lastPick;}if(e.type==='success')s.unscoped.success++;if(e.type==='rate_limited')s.unscoped.rate++;if(e.type==='network_retryable')s.unscoped.retry++;if(e.type==='auth_failed')s.unscoped.auth++;if(e.type==='transport_aborted')s.unscoped.abort++;if(e.type==='client_error'||e.type==='provider_error')s.unscoped.other++;if(isLease)s.unscoped.lease++;if(e.type==='transient_status')s.unscoped.transient++;s.unscoped.last=e.ts||s.unscoped.last;}}const now=Date.now();for(const s of stats.values()){ageStalePending(s,now);for(const m of s.models.values())ageStalePending(m,now);if(s.unscoped)ageStalePending(s.unscoped,now);}return stats;}
|
| 68 |
+
function ageStalePending(v,now){const pending=pendingCount(v);if(!pending)return;const ts=Date.parse(v.lastPick||v.last||'');if(Number.isFinite(ts)&&now-ts>STALE_PENDING_MS){v.stalePending=(v.stalePending||0)+pending;}}
|
| 69 |
function providerRows(provider,stats){return (provider?.keys||[]).map(k=>stats.get(keyId(provider.name,k.slot,k.key))||emptyStat(provider.name,k.slot,k.total,k.key));}
|
| 70 |
+
function pendingCount(v){return Math.max(0,(v.picks||0)-((v.success||0)+(v.rate||0)+(v.retry||0)+(v.transient||0)+(v.auth||0)+(v.abort||0)+(v.other||0)));}
|
| 71 |
+
function modelChips(row){const entries=[...row.models.entries()];const un=row.unscoped||{picks:0,success:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0};const unPending=pendingCount(un);if(!entries.length){const total=row.picks+row.success+row.rate+row.retry+row.transient+row.auth+(row.abort||0)+(row.other||0);return total?`<span class="model-chip">unscoped Β· p:${row.picks} ok:${row.success} pending:${pendingCount(row)} rl:${row.rate} retry:${row.retry+row.transient}${row.abort?` abort:${row.abort}`:''}${row.other?` other:${row.other}`:''}${row.lease?` lease:${row.lease}`:''}</span>`:'<span class="model-chip">no model events yet</span>';}const chips=entries.map(([m,v])=>{const onlyObserved=v.observed&&!v.picks&&!v.success&&!v.rate&&!v.retry&&!v.transient&&!v.auth&&!v.abort&&!v.other;return `<span class="model-chip">${esc(m)} Β· ${onlyObserved?'observed':`p:${v.picks} ok:${v.success} pending:${pendingCount(v)} rl:${v.rate} retry:${v.retry+v.transient}${v.abort?` abort:${v.abort}`:''}${v.other?` other:${v.other}`:''}${v.lease?` lease:${v.lease}`:''}`}</span>`;});if(un.picks||un.success||un.rate||un.retry||un.transient||un.auth||un.abort||un.other)chips.push(`<span class="model-chip">unscoped totals Β· p:${un.picks} ok:${un.success} pending:${unPending} rl:${un.rate} retry:${un.retry+un.transient}${un.abort?` abort:${un.abort}`:''}${un.other?` other:${un.other}`:''}${un.lease?` lease:${un.lease}`:''}</span>`);return chips.join('');}
|
| 72 |
function routeNote(provider){const routes=(state.runtime&&state.runtime.routes)||[],hits=routes.filter(r=>r.provider===provider.name);if(!routes.length)return '<div class="session-note">Current LLM route is unknown to this view. Usage appears only after OpenClaw sends a request to this provider hostname.</div>';if(hits.length)return `<div class="session-note"><b>Active route:</b> ${hits.map(r=>`${esc(r.role)} ${esc(r.model)}`).join(' Β· ')}. New messages using this route should create pick/success events below.</div>`;return `<div class="session-note bad"><b>Not on current model route.</b> Current route is ${routes.map(r=>`${esc(r.role)} ${esc(r.model)} β ${esc(r.provider)}`).join(' Β· ')}. ${esc(provider.name)} keys stay unused until you select a ${esc(provider.name)} model or a fallback reaches it.</div>`;}
|
| 73 |
+
function renderProviderDetails(provider,stats){if(!provider){$('detailTitle').textContent='Provider details';$('details').innerHTML='<div class="empty">Select a provider to see key/session usage.</div>';return;}const rows=providerRows(provider,stats),used=rows.filter(r=>r.picks||r.success).length,rates=rows.reduce((a,r)=>a+r.rate,0),retries=rows.reduce((a,r)=>a+r.retry+r.transient,0),auths=rows.reduce((a,r)=>a+r.auth,0),aborts=rows.reduce((a,r)=>a+(r.abort||0),0),pending=rows.reduce((a,r)=>a+pendingCount(r),0);$('detailTitle').textContent=`${provider.name} Β· provider session`;const modelSet=new Set();rows.forEach(r=>r.models.forEach((_,m)=>modelSet.add(m)));const table=rows.map(r=>{const usedKey=(r.picks||r.success)>0,pending=pendingCount(r),stale=r.stalePending||0;return `<tr><td><b class="mono">#${r.slot}/${r.total}</b><div class="key">${esc(r.key)}</div></td><td><span class="status ${usedKey?'used':'unused'}">${usedKey?'USED':'UNUSED'}</span><div class="metric-detail">last: ${esc(r.last?fmtTime(r.last):'never')}</div>${pending?`<div class="metric-detail warn">pending/no response observed: ${pending}${stale?` (stale ${stale})`:''}</div>`:''}</td><td>pick ${r.picks}<br>success ${r.success}<br>pending ${pending}<br>rate ${r.rate}</td><td>retry ${r.retry}<br>transient ${r.transient}<br>auth ${r.auth}<br>abort ${r.abort||0}<br>other ${r.other||0}<br>lease ${r.lease||0}</td><td>${modelChips(r)}</td></tr>`;}).join('');$('details').innerHTML=`${routeNote(provider)}<div class="session-note">Session: <b>${esc(provider.name)}</b>. Raw keys are never returned here; this page only shows masked keys and slot numbers from the configured pool.</div><div class="detail-grid"><div class="mini"><span class="metric-title">Total keys</span><b>${provider.total||0}</b></div><div class="mini"><span class="metric-title">Used / unused</span><b>${used}/${Math.max(0,(provider.total||0)-used)}</b></div><div class="mini"><span class="metric-title">Rate / pending</span><b>${rates}/${pending}</b></div><div class="mini"><span class="metric-title">Retry/auth/abort</span><b>${retries}/${auths}/${aborts}</b></div></div>${provider.name==='gemini'?`<p class="foot">Gemini quota is tracked per key + model. Models seen: ${modelSet.size?[...modelSet].map(m=>`<span class="model-chip">${esc(m)}</span>`).join(''):'<span class="kbd">none yet β new requests will show per-model once the rotator can read the request body</span>'}</p>`:''}<div class="key-table-wrap"><table class="key-table"><thead><tr><th>Hidden key slot</th><th>Status</th><th>Usage</th><th>Failures / aborts</th><th>Per-model / details</th></tr></thead><tbody>${table||'<tr><td colspan="5">No keys configured</td></tr>'}</tbody></table></div>`;}
|
| 74 |
function render(){const q=$('q').value.trim().toLowerCase(),type=$('type').value,providers=state.providers||[],events=state.events||[],stats=buildStats();if(!providers.some(p=>p.name===state.selected)&&providers[0])state.selected=providers[0].name;const totalKeys=providers.reduce((a,p)=>a+(Number(p.total)||0),0),usedKeyCount=[...stats.values()].filter(s=>s.picks||s.success).length;$('mKeys').textContent=totalKeys;$('mUsed').textContent=usedKeyCount;$('mUnused').textContent=Math.max(0,totalKeys-usedKeyCount);$('mRate').textContent=events.filter(e=>e.type==='rate_limited').length;$('mUpdate').textContent=new Date().toLocaleTimeString();$('providerCount').textContent=`${providers.length} active`;$('eventCount').textContent=`${events.length} events`;$('providers').innerHTML=providers.length?providers.map(p=>{const rows=providerRows(p,stats),used=rows.filter(r=>r.picks||r.success).length,total=Number(p.total)||0,aliases=p.aliases?' Β· '+esc(p.aliases):'';return `<button class="provider ${p.name===state.selected?'active':''}" data-provider="${esc(p.name)}" type="button"><div class="provider-top"><div class="provider-name">${esc(p.name)}</div><span class="pill">${used}/${total} used</span></div><div class="provider-meta">${esc(p.env||'configured pool')}${aliases}</div><div class="bar"><span style="width:${total?Math.round((used/total)*100):0}%"></span></div></button>`;}).join(''):'<div class="empty">No key pools detected in this process env.</div>';$('providers').querySelectorAll('[data-provider]').forEach(el=>el.onclick=()=>{state.selected=el.dataset.provider;localStorage.setItem('hc.keyRotator.provider',state.selected);render();});renderProviderDetails(providers.find(p=>p.name===state.selected)||providers[0],stats);let filtered=events.slice().reverse().filter(e=>(!type||e.type===type));if($('scope').checked&&state.selected)filtered=filtered.filter(e=>!e.provider||e.provider==='system'||e.provider===state.selected);if(q)filtered=filtered.filter(e=>JSON.stringify(e).toLowerCase().includes(q));$('events').innerHTML=filtered.length?filtered.map(e=>`<article class="event ${eventClass(e.type)}"><div class="time">${esc(fmtTime(e.ts))}</div><div><div class="etype">${esc(e.type||'event')}</div><div class="msg">${esc(eventText(e)||'system event')}</div></div><div class="key">${esc(e.key||'')}</div></article>`).join(''):'<div class="empty">No matching events yet. Send a message or disable provider scope to view other providers.</div>';}
|
| 75 |
async function load(){if(state.paused)return;try{const r=await fetch('/api/key-rotator/logs?limit=1000',{cache:'no-store'});if(r.status===401){location.href='/login?next='+encodeURIComponent('/key-rotator');return;}const d=await r.json();state.events=d.events||[];state.providers=d.providers||[];state.runtime=d.runtime||{routes:[]};state.lastPayload=d;const log=d.log||{};$('source').textContent=log.exists?`${d.file||'event log'} Β· ${Math.round((log.size||0)/1024)} KiB`:`${d.file||'event log'} Β· waiting`;render();}catch(e){$('events').innerHTML='<div class="empty bad">Could not load rotator logs: '+esc(e.message)+'</div>';}}
|
| 76 |
$('q').addEventListener('input',render);$('type').addEventListener('change',render);$('scope').addEventListener('change',render);$('refresh').onclick=load;$('pause').onclick=()=>{state.paused=!state.paused;$('pause').textContent=state.paused?'Resume':'Pause';$('liveDot').classList.toggle('live',!state.paused);$('liveText').textContent=state.paused?'Paused':'Live';};$('export').onclick=()=>{const data=JSON.stringify(state.lastPayload||{providers:state.providers,events:state.events},null,2),blob=new Blob([data],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='huggingclaw-key-rotator.json';a.click();URL.revokeObjectURL(url);};load();setInterval(load,2500);
|
multi-provider-key-rotator.cjs
CHANGED
|
@@ -123,6 +123,10 @@ const ERROR_BODY_SNIFF_MAX_BYTES = Math.max(
|
|
| 123 |
4 * 1024,
|
| 124 |
Math.min(256 * 1024, parseInt(process.env.KEY_ERROR_BODY_SNIFF_MAX_BYTES || '', 10) || 64 * 1024),
|
| 125 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
const USE_SUSPENDED_KEY_AS_LAST_RESORT = !/^(0|false|no|off)$/i.test(
|
| 128 |
String(process.env.KEY_USE_SUSPENDED_AS_LAST_RESORT || 'true').trim(),
|
|
@@ -342,6 +346,22 @@ function normalizeErrorToken(value) {
|
|
| 342 |
return String(value || '').trim().toLowerCase();
|
| 343 |
}
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
function parseProviderErrorInfo(text) {
|
| 346 |
if (!text || typeof text !== 'string') return null;
|
| 347 |
const info = { raw: text.slice(0, ERROR_BODY_SNIFF_MAX_BYTES) };
|
|
@@ -355,14 +375,19 @@ function parseProviderErrorInfo(text) {
|
|
| 355 |
info.reason = err.reason ?? body.reason;
|
| 356 |
info.message = err.message ?? body.message;
|
| 357 |
if (!info.reason && Array.isArray(err.errors) && err.errors[0]) info.reason = err.errors[0].reason;
|
| 358 |
-
if (
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
}
|
| 362 |
}
|
| 363 |
} catch (_) {
|
| 364 |
info.message = text.slice(0, 512);
|
| 365 |
}
|
|
|
|
| 366 |
return info;
|
| 367 |
}
|
| 368 |
|
|
@@ -411,7 +436,54 @@ function providerErrorFields(errorInfo) {
|
|
| 411 |
};
|
| 412 |
}
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
function statusNeedsErrorBodyForScope(status) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
return status === 400 || status === 403 || status === 409 || status === 413 || status === 423 || status === 425 || status === 529;
|
| 416 |
}
|
| 417 |
|
|
@@ -928,7 +1000,7 @@ function beginInFlight(p, key, model = null) {
|
|
| 928 |
// as a key failure: one logical OpenClaw task can legitimately hold an
|
| 929 |
// upstream stream longer than the lease TTL, and rotating/blacklisting on
|
| 930 |
// a bookkeeping timeout can split that task across multiple API keys.
|
| 931 |
-
emitEvent('
|
| 932 |
}
|
| 933 |
}, INFLIGHT_TTL_MS);
|
| 934 |
token.timer.unref?.();
|
|
@@ -1147,10 +1219,11 @@ function setAuthHeader(headers, key) {
|
|
| 1147 |
return { authorization: val };
|
| 1148 |
}
|
| 1149 |
|
| 1150 |
-
function handleStatus(p, key, status, model, retryAfterMs, errorInfo) {
|
| 1151 |
if (!p || !key) return;
|
|
|
|
| 1152 |
const failureKind = classifyProviderFailure(p, status, errorInfo);
|
| 1153 |
-
const errorFields = providerErrorFields(errorInfo);
|
| 1154 |
|
| 1155 |
if (failureKind === 'auth') {
|
| 1156 |
// Invalid/expired/unauthorized key β always a global (not model-scoped) blacklist.
|
|
@@ -1161,8 +1234,9 @@ function handleStatus(p, key, status, model, retryAfterMs, errorInfo) {
|
|
| 1161 |
ks.blacklistedUntil = Date.now() + PERM_SUSPEND_MS;
|
| 1162 |
clearStickyKey(p, key);
|
| 1163 |
clearTaskAffinityKey(p, key);
|
| 1164 |
-
|
| 1165 |
-
|
|
|
|
| 1166 |
return;
|
| 1167 |
}
|
| 1168 |
|
|
@@ -1194,6 +1268,18 @@ function handleStatus(p, key, status, model, retryAfterMs, errorInfo) {
|
|
| 1194 |
if (status >= 200 && status < 400) {
|
| 1195 |
recordSuccess(p, key, model);
|
| 1196 |
emitEvent('success', p, key, { status, model });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
}
|
| 1198 |
}
|
| 1199 |
|
|
@@ -1202,13 +1288,36 @@ function handleTransportError(p, key, err, model = null) {
|
|
| 1202 |
// Node.js 18+ undici fetch throws TypeError: "fetch failed" where the actual
|
| 1203 |
// network error code lives in err.cause.code (e.g. ECONNRESET, ETIMEDOUT,
|
| 1204 |
// ENOTFOUND). Fall back to err.cause.code so retryable network errors are
|
| 1205 |
-
// correctly classified and transient blacklists are applied.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1206 |
const code = (err?.code || err?.cause?.code)
|
| 1207 |
? String(err.code || err.cause?.code).toUpperCase()
|
| 1208 |
: '';
|
| 1209 |
const name = String(err?.name || '');
|
| 1210 |
const message = String(err?.message || err?.cause?.message || '');
|
| 1211 |
const haystack = `${name} ${message}`.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1212 |
const looksRateOrQuota = /rate.?limit|too.?many|quota|resource.?exhaust|usage.?limit|insufficient.?quota|capacity.?exceeded|tokens?.?per|requests?.?per|rate_limit|rate.?limited|userratelimit|dailylimit|limitexceeded/.test(haystack);
|
| 1213 |
if (looksRateOrQuota) {
|
| 1214 |
recordFailure(p, key, model, 0);
|
|
@@ -1218,11 +1327,6 @@ function handleTransportError(p, key, err, model = null) {
|
|
| 1218 |
emitEvent('rate_limited', p, key, { model, name: name || 'Error', code, source: 'transport_error', message: message.slice(0, 240) });
|
| 1219 |
return;
|
| 1220 |
}
|
| 1221 |
-
if (isCallerAbortError(err)) {
|
| 1222 |
-
debug(`[key-rotator] ${p.name}: caller abort ${name || 'AbortError'} on ${keySlot(p, key)}${keyMask(key)}${model ? ` model=${model}` : ''} β leaving key sticky/healthy`);
|
| 1223 |
-
emitEvent('transport_aborted', p, key, { model, name: name || 'AbortError', code, message: message.slice(0, 240), classifiedAs: 'caller_abort' });
|
| 1224 |
-
return;
|
| 1225 |
-
}
|
| 1226 |
const retryable = shouldRetryTransportError(err, code);
|
| 1227 |
if (retryable) {
|
| 1228 |
recordTransientFailure(p, key, model);
|
|
@@ -1691,6 +1795,7 @@ function wrapUndiciHandler(handler, provider, key, inFlightToken, getModel) {
|
|
| 1691 |
let settled = false;
|
| 1692 |
let statusHandled = false;
|
| 1693 |
let errorBytes = 0;
|
|
|
|
| 1694 |
const errorChunks = [];
|
| 1695 |
const currentModel = () => (typeof getModel === 'function' ? getModel() : null);
|
| 1696 |
const collectErrorBody = (chunk) => {
|
|
@@ -1708,6 +1813,12 @@ function wrapUndiciHandler(handler, provider, key, inFlightToken, getModel) {
|
|
| 1708 |
if (!errorChunks.length) return null;
|
| 1709 |
try { return parseProviderErrorInfo(Buffer.concat(errorChunks).toString('utf8')); } catch (_) { return null; }
|
| 1710 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1711 |
const handleHeadersStatus = (force = false) => {
|
| 1712 |
if (statusHandled || !statusCode) return false;
|
| 1713 |
// Success and unambiguous failures are emitted as soon as headers arrive.
|
|
@@ -1719,12 +1830,14 @@ function wrapUndiciHandler(handler, provider, key, inFlightToken, getModel) {
|
|
| 1719 |
// quota vs global auth suspension.
|
| 1720 |
if (!force && statusNeedsErrorBodyForScope(statusCode)) return false;
|
| 1721 |
statusHandled = true;
|
|
|
|
| 1722 |
try { handleStatus(provider, key, statusCode, currentModel(), retryAfterMs, currentErrorInfo()); } catch (_) {}
|
| 1723 |
return true;
|
| 1724 |
};
|
| 1725 |
const settle = (fn) => {
|
| 1726 |
if (settled) return;
|
| 1727 |
settled = true;
|
|
|
|
| 1728 |
try { endInFlight(provider, key, inFlightToken); } catch (_) {}
|
| 1729 |
try { fn(); } catch (_) {}
|
| 1730 |
};
|
|
@@ -1736,8 +1849,17 @@ function wrapUndiciHandler(handler, provider, key, inFlightToken, getModel) {
|
|
| 1736 |
retryAfterMs = parseRetryAfterMs(uGetHeader(headers, 'retry-after'));
|
| 1737 |
if (handleHeadersStatus()) {
|
| 1738 |
// Header-level status accounting is complete; close the in-flight
|
| 1739 |
-
// token now so successful streams do not later report
|
| 1740 |
settle(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1741 |
}
|
| 1742 |
return target.onHeaders ? target.onHeaders.call(target, sc, headers, resume, statusMessage) : undefined;
|
| 1743 |
};
|
|
@@ -2336,7 +2458,7 @@ if (hasProviderKeys) {
|
|
| 2336 |
patchUndici(); // covers OpenClaw gateway's bundled undici AI calls
|
| 2337 |
startDiagnostics();
|
| 2338 |
|
| 2339 |
-
debug(`[key-rotator] loaded β cooldown base:${BASE_COOLDOWN_MS/1000}s max-strikes:${MAX_STRIKES} perm-suspend:${formatHours(PERM_SUSPEND_MS)}h (cap 16h) max-inflight-per-key:${MAX_INFLIGHT_PER_KEY} max-retry-after:${MAX_RETRY_AFTER_MS/1000}s max-key-wait:${MAX_KEY_WAIT_MS/1000}s diagnostics:${DIAGNOSTICS_ENABLED ? 'on' : 'off'} log-level:${LOG_LEVEL} verbose-picks:${VERBOSE_PICKS ? 'on' : 'off'} suspended-last-resort:${USE_SUSPENDED_KEY_AS_LAST_RESORT ? 'on' : 'off'} per-model-providers:${providerState.filter(p => p.perModelLimits).map(p => p.name).join(',') || 'none'} model-from-body:on model-sniff-max:${REQUEST_MODEL_SNIFF_MAX_BYTES} error-sniff-max:${ERROR_BODY_SNIFF_MAX_BYTES} inflight-ttl:${INFLIGHT_TTL_MS}ms task-affinity:${TASK_AFFINITY_MS}ms/${TASK_AFFINITY_MAX_REUSES}reuses sticky-until-failure:${STICKY_UNTIL_FAILURE ? 'on' : 'off'} sticky-scope:${String(process.env.KEY_STICKY_SCOPE || 'auto').trim().toLowerCase() || 'auto'} sticky-providers:${[...STICKY_PROVIDER_SET].join(',') || 'none'} llm-fallback-providers:${LLM_FALLBACK_PROVIDER_SET ? [...LLM_FALLBACK_PROVIDER_SET].join(',') : 'all'}`);
|
| 2340 |
emitEvent('rotator_loaded', null, null, {
|
| 2341 |
providers: providerState.filter(p => p.keys.length).map(p => ({ name: p.name, total: p.keys.length })),
|
| 2342 |
logLevel: LOG_LEVEL,
|
|
|
|
| 123 |
4 * 1024,
|
| 124 |
Math.min(256 * 1024, parseInt(process.env.KEY_ERROR_BODY_SNIFF_MAX_BYTES || '', 10) || 64 * 1024),
|
| 125 |
);
|
| 126 |
+
const ERROR_BODY_WAIT_MS = Math.max(
|
| 127 |
+
250,
|
| 128 |
+
Math.min(10_000, parseInt(process.env.KEY_ERROR_BODY_WAIT_MS || '', 10) || 1500),
|
| 129 |
+
);
|
| 130 |
|
| 131 |
const USE_SUSPENDED_KEY_AS_LAST_RESORT = !/^(0|false|no|off)$/i.test(
|
| 132 |
String(process.env.KEY_USE_SUSPENDED_AS_LAST_RESORT || 'true').trim(),
|
|
|
|
| 346 |
return String(value || '').trim().toLowerCase();
|
| 347 |
}
|
| 348 |
|
| 349 |
+
function retryDelayMsFromText(text) {
|
| 350 |
+
const raw = String(text || '');
|
| 351 |
+
if (!raw) return 0;
|
| 352 |
+
const retryIn = raw.match(/(?:retry|try again|retry-after|retry_after)[^0-9]{0,40}(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|secs|seconds?|m|mins?|minutes?)?/i);
|
| 353 |
+
if (!retryIn) return 0;
|
| 354 |
+
const value = Number(retryIn[1]);
|
| 355 |
+
if (!Number.isFinite(value) || value < 0) return 0;
|
| 356 |
+
const unit = String(retryIn[2] || 's').toLowerCase();
|
| 357 |
+
const ms = unit.startsWith('m') && !unit.startsWith('ms') && !unit.startsWith('milli')
|
| 358 |
+
? value * 60_000
|
| 359 |
+
: unit.startsWith('ms') || unit.startsWith('milli')
|
| 360 |
+
? value
|
| 361 |
+
: value * 1000;
|
| 362 |
+
return Math.min(MAX_RETRY_AFTER_MS, Math.round(ms));
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
function parseProviderErrorInfo(text) {
|
| 366 |
if (!text || typeof text !== 'string') return null;
|
| 367 |
const info = { raw: text.slice(0, ERROR_BODY_SNIFF_MAX_BYTES) };
|
|
|
|
| 375 |
info.reason = err.reason ?? body.reason;
|
| 376 |
info.message = err.message ?? body.message;
|
| 377 |
if (!info.reason && Array.isArray(err.errors) && err.errors[0]) info.reason = err.errors[0].reason;
|
| 378 |
+
if (Array.isArray(err.details)) {
|
| 379 |
+
if (!info.reason) {
|
| 380 |
+
const quota = err.details.find(d => d && (d.reason || d.violations || d.quotaMetric));
|
| 381 |
+
if (quota) info.reason = quota.reason || quota.quotaMetric || 'quota_details';
|
| 382 |
+
}
|
| 383 |
+
const retry = err.details.find(d => d && (d.retryDelay || d.retry_delay));
|
| 384 |
+
if (retry) info.retryAfterMs = retryDelayMsFromText(`retry in ${retry.retryDelay || retry.retry_delay}`);
|
| 385 |
}
|
| 386 |
}
|
| 387 |
} catch (_) {
|
| 388 |
info.message = text.slice(0, 512);
|
| 389 |
}
|
| 390 |
+
if (!info.retryAfterMs) info.retryAfterMs = retryDelayMsFromText(info.message || text);
|
| 391 |
return info;
|
| 392 |
}
|
| 393 |
|
|
|
|
| 436 |
};
|
| 437 |
}
|
| 438 |
|
| 439 |
+
function extractStatusFromError(err) {
|
| 440 |
+
const direct = [
|
| 441 |
+
err?.status,
|
| 442 |
+
err?.statusCode,
|
| 443 |
+
err?.code,
|
| 444 |
+
err?.response?.status,
|
| 445 |
+
err?.response?.statusCode,
|
| 446 |
+
err?.cause?.status,
|
| 447 |
+
err?.cause?.statusCode,
|
| 448 |
+
err?.cause?.response?.status,
|
| 449 |
+
err?.cause?.response?.statusCode,
|
| 450 |
+
];
|
| 451 |
+
for (const value of direct) {
|
| 452 |
+
if (typeof value === 'number' && value >= 100 && value <= 599) return value;
|
| 453 |
+
if (typeof value === 'string' && /^\d{3}$/.test(value.trim())) {
|
| 454 |
+
const n = Number(value.trim());
|
| 455 |
+
if (n >= 100 && n <= 599) return n;
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
const text = [
|
| 460 |
+
err?.message,
|
| 461 |
+
err?.cause?.message,
|
| 462 |
+
err?.stack,
|
| 463 |
+
].filter(Boolean).join(' ');
|
| 464 |
+
const match = text.match(/(?:^|\D)([1-5]\d{2})\s*(?:status(?:\s+code)?|http|response|provider)/i)
|
| 465 |
+
|| text.match(/(?:status(?:\s+code)?|http|response|provider)\D+([1-5]\d{2})(?:\D|$)/i)
|
| 466 |
+
|| text.match(/^\s*(?:[A-Za-z][A-Za-z0-9_]*Error:\s*)?([1-5]\d{2})\b/)
|
| 467 |
+
|| text.match(/\b([1-5]\d{2})\s+(?:RESOURCE_EXHAUSTED|UNAVAILABLE|INTERNAL|DEADLINE_EXCEEDED|PERMISSION_DENIED|UNAUTHENTICATED|INVALID_ARGUMENT|FAILED_PRECONDITION|NOT_FOUND|overloaded|too many|unauthori[sz]ed|forbidden|rate)/i);
|
| 468 |
+
return match ? Number(match[1]) : null;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
function transportErrorInfo(err, status) {
|
| 472 |
+
const message = String(err?.message || err?.cause?.message || '').slice(0, 512);
|
| 473 |
+
const info = parseProviderErrorInfo(message) || { message };
|
| 474 |
+
if (!info.message) info.message = message;
|
| 475 |
+
if (typeof status === 'number') info.status = String(status);
|
| 476 |
+
if (err?.type) info.type = err.type;
|
| 477 |
+
if (err?.reason) info.reason = err.reason;
|
| 478 |
+
return info;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
function statusNeedsErrorBodyForScope(status) {
|
| 482 |
+
// Wait for the body only where provider text can materially change key
|
| 483 |
+
// health classification (notably Google/Gemini 403 quota vs permission).
|
| 484 |
+
// 401/402/429/5xx are unambiguous from headers; handling them immediately
|
| 485 |
+
// avoids 30s lease-expiry noise when OpenClaw aborts/failover stops reading
|
| 486 |
+
// the error body.
|
| 487 |
return status === 400 || status === 403 || status === 409 || status === 413 || status === 423 || status === 425 || status === 529;
|
| 488 |
}
|
| 489 |
|
|
|
|
| 1000 |
// as a key failure: one logical OpenClaw task can legitimately hold an
|
| 1001 |
// upstream stream longer than the lease TTL, and rotating/blacklisting on
|
| 1002 |
// a bookkeeping timeout can split that task across multiple API keys.
|
| 1003 |
+
emitEvent('inflight_lease_expired', p, key, { model: token.model || null, inflightBefore: before, inflightAfter: next, ttlMs: INFLIGHT_TTL_MS, classifiedAs: 'lease_cleanup' });
|
| 1004 |
}
|
| 1005 |
}, INFLIGHT_TTL_MS);
|
| 1006 |
token.timer.unref?.();
|
|
|
|
| 1219 |
return { authorization: val };
|
| 1220 |
}
|
| 1221 |
|
| 1222 |
+
function handleStatus(p, key, status, model, retryAfterMs, errorInfo, extra = {}) {
|
| 1223 |
if (!p || !key) return;
|
| 1224 |
+
retryAfterMs = retryAfterMs || errorInfo?.retryAfterMs || 0;
|
| 1225 |
const failureKind = classifyProviderFailure(p, status, errorInfo);
|
| 1226 |
+
const errorFields = { ...providerErrorFields(errorInfo), ...extra };
|
| 1227 |
|
| 1228 |
if (failureKind === 'auth') {
|
| 1229 |
// Invalid/expired/unauthorized key β always a global (not model-scoped) blacklist.
|
|
|
|
| 1234 |
ks.blacklistedUntil = Date.now() + PERM_SUSPEND_MS;
|
| 1235 |
clearStickyKey(p, key);
|
| 1236 |
clearTaskAffinityKey(p, key);
|
| 1237 |
+
const reason = errorFields.errorReason || errorFields.errorStatus || errorFields.errorType || errorFields.errorCode || errorFields.source;
|
| 1238 |
+
warn(`[key-rotator] ${p.name}: ${keySlot(p, key)}${keyMask(key)} auth-failed (${status})${reason ? ` reason=${reason}` : ''}${model ? ` model=${model}` : ''} β suspended for ${formatHours(PERM_SUSPEND_MS)} h`);
|
| 1239 |
+
emitEvent('auth_failed', p, key, { status, model, suspendMs: PERM_SUSPEND_MS, ...errorFields });
|
| 1240 |
return;
|
| 1241 |
}
|
| 1242 |
|
|
|
|
| 1268 |
if (status >= 200 && status < 400) {
|
| 1269 |
recordSuccess(p, key, model);
|
| 1270 |
emitEvent('success', p, key, { status, model });
|
| 1271 |
+
return;
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
if (status >= 400) {
|
| 1275 |
+
// Non-key failures (malformed request, missing model, unsupported feature,
|
| 1276 |
+
// content/safety/request-size errors, etc.) still need an explicit outcome
|
| 1277 |
+
// so the dashboard does not age a completed request into a fake transient.
|
| 1278 |
+
// Do not blacklist or rotate on these: the selected API key may be healthy.
|
| 1279 |
+
const eventType = status < 500 ? 'client_error' : 'provider_error';
|
| 1280 |
+
const reason = errorFields.errorReason || errorFields.errorStatus || errorFields.errorType || errorFields.errorCode || errorFields.source;
|
| 1281 |
+
warn(`[key-rotator] ${p.name}: ${eventType} status=${status}${reason ? ` reason=${reason}` : ''} on ${keySlot(p, key)}${keyMask(key)}${model ? ` model=${model}` : ''} (key not penalized)`);
|
| 1282 |
+
emitEvent(eventType, p, key, { status, model, ...errorFields });
|
| 1283 |
}
|
| 1284 |
}
|
| 1285 |
|
|
|
|
| 1288 |
// Node.js 18+ undici fetch throws TypeError: "fetch failed" where the actual
|
| 1289 |
// network error code lives in err.cause.code (e.g. ECONNRESET, ETIMEDOUT,
|
| 1290 |
// ENOTFOUND). Fall back to err.cause.code so retryable network errors are
|
| 1291 |
+
// correctly classified and transient blacklists are applied. OpenClaw
|
| 1292 |
+
// failover can also throw FailoverError("401 status code (no body)") instead
|
| 1293 |
+
// of returning a Response; parse that embedded HTTP status so the selected
|
| 1294 |
+
// key is marked auth/rate/transient correctly instead of becoming a stale
|
| 1295 |
+
// pending pick on the dashboard.
|
| 1296 |
const code = (err?.code || err?.cause?.code)
|
| 1297 |
? String(err.code || err.cause?.code).toUpperCase()
|
| 1298 |
: '';
|
| 1299 |
const name = String(err?.name || '');
|
| 1300 |
const message = String(err?.message || err?.cause?.message || '');
|
| 1301 |
const haystack = `${name} ${message}`.toLowerCase();
|
| 1302 |
+
if (isCallerAbortError(err)) {
|
| 1303 |
+
debug(`[key-rotator] ${p.name}: caller abort ${name || 'AbortError'} on ${keySlot(p, key)}${keyMask(key)}${model ? ` model=${model}` : ''} β leaving key sticky/healthy`);
|
| 1304 |
+
emitEvent('transport_aborted', p, key, { model, name: name || 'AbortError', code, message: message.slice(0, 240), classifiedAs: 'caller_abort' });
|
| 1305 |
+
return;
|
| 1306 |
+
}
|
| 1307 |
+
|
| 1308 |
+
const embeddedStatus = extractStatusFromError(err);
|
| 1309 |
+
if (embeddedStatus) {
|
| 1310 |
+
const errorInfo = transportErrorInfo(err, embeddedStatus);
|
| 1311 |
+
warn(`[key-rotator] ${p.name}: transport status=${embeddedStatus} ${name || 'Error'} on ${keySlot(p, key)}${keyMask(key)}${model ? ` model=${model}` : ''}${message ? ` message=${JSON.stringify(message.slice(0, 160))}` : ''}`);
|
| 1312 |
+
handleStatus(p, key, embeddedStatus, model, 0, errorInfo, {
|
| 1313 |
+
source: 'transport_error',
|
| 1314 |
+
...(name ? { name } : {}),
|
| 1315 |
+
...(code ? { code } : {}),
|
| 1316 |
+
...(message ? { message: message.slice(0, 240) } : {}),
|
| 1317 |
+
});
|
| 1318 |
+
return;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
const looksRateOrQuota = /rate.?limit|too.?many|quota|resource.?exhaust|usage.?limit|insufficient.?quota|capacity.?exceeded|tokens?.?per|requests?.?per|rate_limit|rate.?limited|userratelimit|dailylimit|limitexceeded/.test(haystack);
|
| 1322 |
if (looksRateOrQuota) {
|
| 1323 |
recordFailure(p, key, model, 0);
|
|
|
|
| 1327 |
emitEvent('rate_limited', p, key, { model, name: name || 'Error', code, source: 'transport_error', message: message.slice(0, 240) });
|
| 1328 |
return;
|
| 1329 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1330 |
const retryable = shouldRetryTransportError(err, code);
|
| 1331 |
if (retryable) {
|
| 1332 |
recordTransientFailure(p, key, model);
|
|
|
|
| 1795 |
let settled = false;
|
| 1796 |
let statusHandled = false;
|
| 1797 |
let errorBytes = 0;
|
| 1798 |
+
let bodyWaitTimer = null;
|
| 1799 |
const errorChunks = [];
|
| 1800 |
const currentModel = () => (typeof getModel === 'function' ? getModel() : null);
|
| 1801 |
const collectErrorBody = (chunk) => {
|
|
|
|
| 1813 |
if (!errorChunks.length) return null;
|
| 1814 |
try { return parseProviderErrorInfo(Buffer.concat(errorChunks).toString('utf8')); } catch (_) { return null; }
|
| 1815 |
};
|
| 1816 |
+
const clearBodyWaitTimer = () => {
|
| 1817 |
+
if (bodyWaitTimer) {
|
| 1818 |
+
clearTimeout(bodyWaitTimer);
|
| 1819 |
+
bodyWaitTimer = null;
|
| 1820 |
+
}
|
| 1821 |
+
};
|
| 1822 |
const handleHeadersStatus = (force = false) => {
|
| 1823 |
if (statusHandled || !statusCode) return false;
|
| 1824 |
// Success and unambiguous failures are emitted as soon as headers arrive.
|
|
|
|
| 1830 |
// quota vs global auth suspension.
|
| 1831 |
if (!force && statusNeedsErrorBodyForScope(statusCode)) return false;
|
| 1832 |
statusHandled = true;
|
| 1833 |
+
clearBodyWaitTimer();
|
| 1834 |
try { handleStatus(provider, key, statusCode, currentModel(), retryAfterMs, currentErrorInfo()); } catch (_) {}
|
| 1835 |
return true;
|
| 1836 |
};
|
| 1837 |
const settle = (fn) => {
|
| 1838 |
if (settled) return;
|
| 1839 |
settled = true;
|
| 1840 |
+
clearBodyWaitTimer();
|
| 1841 |
try { endInFlight(provider, key, inFlightToken); } catch (_) {}
|
| 1842 |
try { fn(); } catch (_) {}
|
| 1843 |
};
|
|
|
|
| 1849 |
retryAfterMs = parseRetryAfterMs(uGetHeader(headers, 'retry-after'));
|
| 1850 |
if (handleHeadersStatus()) {
|
| 1851 |
// Header-level status accounting is complete; close the in-flight
|
| 1852 |
+
// token now so successful streams do not later report lease expiry.
|
| 1853 |
settle(() => {});
|
| 1854 |
+
} else if (statusNeedsErrorBodyForScope(statusCode) && !bodyWaitTimer) {
|
| 1855 |
+
// Some OpenClaw failover paths stop reading an error response once a
|
| 1856 |
+
// higher layer has enough information to throw. Do not let an
|
| 1857 |
+
// ambiguous 4xx wait for the 30s in-flight lease: give the provider
|
| 1858 |
+
// body a short chance to arrive, then classify with whatever we saw.
|
| 1859 |
+
bodyWaitTimer = setTimeout(() => {
|
| 1860 |
+
settle(() => { handleHeadersStatus(true); });
|
| 1861 |
+
}, ERROR_BODY_WAIT_MS);
|
| 1862 |
+
bodyWaitTimer.unref?.();
|
| 1863 |
}
|
| 1864 |
return target.onHeaders ? target.onHeaders.call(target, sc, headers, resume, statusMessage) : undefined;
|
| 1865 |
};
|
|
|
|
| 2458 |
patchUndici(); // covers OpenClaw gateway's bundled undici AI calls
|
| 2459 |
startDiagnostics();
|
| 2460 |
|
| 2461 |
+
debug(`[key-rotator] loaded β cooldown base:${BASE_COOLDOWN_MS/1000}s max-strikes:${MAX_STRIKES} perm-suspend:${formatHours(PERM_SUSPEND_MS)}h (cap 16h) max-inflight-per-key:${MAX_INFLIGHT_PER_KEY} max-retry-after:${MAX_RETRY_AFTER_MS/1000}s max-key-wait:${MAX_KEY_WAIT_MS/1000}s diagnostics:${DIAGNOSTICS_ENABLED ? 'on' : 'off'} log-level:${LOG_LEVEL} verbose-picks:${VERBOSE_PICKS ? 'on' : 'off'} suspended-last-resort:${USE_SUSPENDED_KEY_AS_LAST_RESORT ? 'on' : 'off'} per-model-providers:${providerState.filter(p => p.perModelLimits).map(p => p.name).join(',') || 'none'} model-from-body:on model-sniff-max:${REQUEST_MODEL_SNIFF_MAX_BYTES} error-sniff-max:${ERROR_BODY_SNIFF_MAX_BYTES} error-body-wait:${ERROR_BODY_WAIT_MS}ms inflight-ttl:${INFLIGHT_TTL_MS}ms task-affinity:${TASK_AFFINITY_MS}ms/${TASK_AFFINITY_MAX_REUSES}reuses sticky-until-failure:${STICKY_UNTIL_FAILURE ? 'on' : 'off'} sticky-scope:${String(process.env.KEY_STICKY_SCOPE || 'auto').trim().toLowerCase() || 'auto'} sticky-providers:${[...STICKY_PROVIDER_SET].join(',') || 'none'} llm-fallback-providers:${LLM_FALLBACK_PROVIDER_SET ? [...LLM_FALLBACK_PROVIDER_SET].join(',') : 'all'}`);
|
| 2462 |
emitEvent('rotator_loaded', null, null, {
|
| 2463 |
providers: providerState.filter(p => p.keys.length).map(p => ({ name: p.name, total: p.keys.length })),
|
| 2464 |
logLevel: LOG_LEVEL,
|