Spaces:
Running
Running
sync: 166 file da Baida98/AI@a6ac2424e11e5c320c5ff688e1ce7addac64cdab (local-fallback deploy-all)
#47
by Baida07 - opened
- agents/unified_loop.py +52 -0
- benchmark-extended.mjs +95 -38
- benchmarks/__init__.py +41 -0
- benchmarks/model_watch_adapter.py +279 -0
- benchmarks/shadow_telemetry.py +125 -0
- benchmarks/validators.py +351 -0
- models/ai_client.py +22 -5
- tests/test_ai_client_provider_unavailability.py +9 -3
- tests/test_benchmark_validators.py +232 -0
- tests/test_model_watch_adapter.py +192 -0
- tests/test_provider_model_defaults.py +4 -0
- tests/test_provider_profile_pool.py +21 -1
- tests/test_shadow_telemetry.py +64 -0
agents/unified_loop.py
CHANGED
|
@@ -2231,6 +2231,58 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 2231 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2232 |
except Exception as _exc:
|
| 2233 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2234 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2235 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2236 |
if _fr == 'length' and on_step:
|
|
|
|
| 2231 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2232 |
except Exception as _exc:
|
| 2233 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 2234 |
+
# BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
|
| 2235 |
+
# modifica answer, retry, provider routing o scoring.
|
| 2236 |
+
try:
|
| 2237 |
+
from benchmarks.shadow_telemetry import validate_and_record_shadow
|
| 2238 |
+
validate_and_record_shadow(
|
| 2239 |
+
goal=state.goal,
|
| 2240 |
+
answer=answer,
|
| 2241 |
+
metadata={
|
| 2242 |
+
"provider": getattr(_active_llm, "provider", None),
|
| 2243 |
+
"model": getattr(_active_llm, "model", None),
|
| 2244 |
+
"profile": getattr(_active_llm, "profile", None),
|
| 2245 |
+
"attempt": _llm_try,
|
| 2246 |
+
"latency_ms": round(_llm_elapsed, 2),
|
| 2247 |
+
"source": "unified_loop",
|
| 2248 |
+
},
|
| 2249 |
+
)
|
| 2250 |
+
except Exception as _exc:
|
| 2251 |
+
_logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
|
| 2252 |
+
|
| 2253 |
+
# BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
|
| 2254 |
+
# non estraibile/non conforme. Non aggiunge tentativi oltre il budget
|
| 2255 |
+
# esistente e non scatta su goal non-coding.
|
| 2256 |
+
if not _is_last:
|
| 2257 |
+
try:
|
| 2258 |
+
from benchmarks.validators import validate_coding_retry
|
| 2259 |
+
_code_validation = validate_coding_retry(
|
| 2260 |
+
state.goal,
|
| 2261 |
+
answer,
|
| 2262 |
+
is_last_attempt=_is_last,
|
| 2263 |
+
)
|
| 2264 |
+
if _code_validation is not None:
|
| 2265 |
+
state.steps.append({
|
| 2266 |
+
"action": f"typescript_contract_retry_{_llm_try}",
|
| 2267 |
+
"failure_code": _code_validation.failure_code,
|
| 2268 |
+
})
|
| 2269 |
+
_code_repair = (
|
| 2270 |
+
"CONTRATTO TYPESCRIPT FALLITO: "
|
| 2271 |
+
f"{_code_validation.failure_code}.\n"
|
| 2272 |
+
"Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
|
| 2273 |
+
"```typescript ... ``` non vuoto, completo e compilabile. "
|
| 2274 |
+
"Mantieni la firma e tutti i simboli richiesti dal task. "
|
| 2275 |
+
"Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
|
| 2276 |
+
)
|
| 2277 |
+
messages = [
|
| 2278 |
+
messages[0],
|
| 2279 |
+
{"role": "system", "content": _code_repair},
|
| 2280 |
+
*messages[1:],
|
| 2281 |
+
]
|
| 2282 |
+
_error_severity = "syntax"
|
| 2283 |
+
continue
|
| 2284 |
+
except Exception as _exc:
|
| 2285 |
+
_logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
|
| 2286 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2287 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2288 |
if _fr == 'length' and on_step:
|
benchmark-extended.mjs
CHANGED
|
@@ -314,23 +314,33 @@ FSH.autonomy =
|
|
| 314 |
// e ritenta 1 volta se la risposta è troppo corta (cold start / timeout transitorio)
|
| 315 |
async function callAgentWithRetry(task, timeoutMs) {
|
| 316 |
const hint = FSH[task.category];
|
| 317 |
-
// Append FSH hint so the agent knows the expected output format
|
| 318 |
const goal = hint
|
| 319 |
-
? `${task.prompt}
|
| 320 |
-
|
| 321 |
-
---
|
| 322 |
-
📌 FORMATO RISPOSTA ATTESO:
|
| 323 |
-
${hint}`
|
| 324 |
: task.prompt;
|
| 325 |
const a = await callAgent(goal, timeoutMs);
|
| 326 |
// Infrastructure failures are not model answers and must not be retried/scored.
|
| 327 |
if (a.failed) return a;
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
if ((a.output||"").length < 40 && !(a.output||"").includes("TIMEOUT")) {
|
| 330 |
if(!F_JSON) process.stdout.write(" ⟳ retry (risposta vuota)... ");
|
| 331 |
await new Promise(r => setTimeout(r, 4000));
|
| 332 |
const a2 = await callAgent(goal, timeoutMs);
|
| 333 |
-
if (!F_JSON) process.stdout.write("ok\n");
|
| 334 |
return (a2.output||"").length > (a.output||"").length ? a2 : a;
|
| 335 |
}
|
| 336 |
return a;
|
|
@@ -535,13 +545,25 @@ async function runCmd(cmd,args=[],cwd="/tmp",ms=20000){
|
|
| 535 |
});
|
| 536 |
}
|
| 537 |
function extractCode(out,langs){
|
| 538 |
-
|
|
|
|
|
|
|
| 539 |
for(const m of out.matchAll(re)){
|
| 540 |
const l=(m[1]||"").toLowerCase(),c=m[2].trimEnd();
|
| 541 |
if((langs.includes(l)||langs.includes("*"))&&c.length>best.len) best={code:c,len:c.length};
|
| 542 |
}
|
| 543 |
return best.code;
|
| 544 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
// ── LLM-as-Judge (GROQ/Cerebras semantic scoring) ────────────────────────────
|
| 546 |
const _JUDGE_CACHE = new Map();
|
| 547 |
|
|
@@ -633,6 +655,27 @@ async function fetchGSM8K(seed){
|
|
| 633 |
return{question:row.question,expectedAnswer:parseInt(m[1].replace(/,/g,"")),
|
| 634 |
offset:hfOffset(seed,HF_GSM8K),source:"openai/gsm8k"};
|
| 635 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
async function fetchBBH(seed){
|
| 637 |
const row=await hfRow("lukaemon/bbh","logical_deduction_three_objects","test",hfOffset(seed,HF_BBH,3571));
|
| 638 |
if(!row?.input)return null;
|
|
@@ -672,35 +715,51 @@ async function callAgent(goal,timeoutMs=90000){
|
|
| 672 |
if(!res.body)return{ok:false,output:"",engine,ttfa,toolCalls,durationMs:Date.now()-t0,failed:true,failureReason:"stream body mancante"};
|
| 673 |
const dec=new TextDecoder(); let buf="";
|
| 674 |
const rd=res.body.getReader();
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
const
|
| 682 |
-
if(
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
const chunk=String(ev.token||ev.content); const trimmed=chunk.trim();
|
| 689 |
if(!(trimmed.startsWith("{")&&trimmed.endsWith("}")))out+=chunk;
|
| 690 |
ttfa=ttfa??now;
|
| 691 |
-
} else if(ev.type==="task_error"){
|
| 692 |
-
failed=true;failureReason=String(ev.error||"task_error");done=true;break loop;
|
| 693 |
-
} else if(ev.type==="task_done"){
|
| 694 |
-
const result=typeof ev.result==="string"?ev.result:"";
|
| 695 |
-
if(ev.success===false||result.startsWith("[LLM_UNAVAILABLE]")||result.includes("tutti i provider configurati sono falliti")){
|
| 696 |
-
failed=true;failureReason=result||"provider_unavailable";engine=ev.engine??engine;done=true;break loop;
|
| 697 |
-
}
|
| 698 |
-
if(result&&result.length>(out||"").length)out=result;
|
| 699 |
-
engine=ev.engine??engine;done=true;break loop;
|
| 700 |
}
|
| 701 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
}
|
| 703 |
}
|
|
|
|
|
|
|
| 704 |
rd.cancel?.();
|
| 705 |
}catch(e){
|
| 706 |
if(taskId){
|
|
@@ -1357,8 +1416,7 @@ async function makeMMLU(rng,seed){
|
|
| 1357 |
hfSource:"cais/mmlu/"+subject,hfOffset:offset,targetMs:30000,ref:REF.mmlu,
|
| 1358 |
prompt:"Domanda di informatica a scelta multipla:\n\n"+row.question+"\n\nA) "+row.choices[0]+"\nB) "+row.choices[1]+"\nC) "+row.choices[2]+"\nD) "+row.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
|
| 1359 |
verify:async function(o){
|
| 1360 |
-
const
|
| 1361 |
-
const got=m?(m[1]||m[2]||m[3]||m[4]):null;
|
| 1362 |
const correct=got===ansLetter;
|
| 1363 |
const hasExp=o.length>60&&/perch|quindi|perche|because|quindi|questo|in quanto/i.test(o);
|
| 1364 |
const judge=await judgeWithLLM(row.question,o,{
|
|
@@ -1378,8 +1436,7 @@ async function makeMMLU(rng,seed){
|
|
| 1378 |
hfSource:"local-cs-fundamentals",targetMs:30000,ref:REF.mmlu,
|
| 1379 |
prompt:"Domanda di informatica a scelta multipla:\n\n"+f.q+"\n\nA) "+f.choices[0]+"\nB) "+f.choices[1]+"\nC) "+f.choices[2]+"\nD) "+f.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
|
| 1380 |
verify:async function(o){
|
| 1381 |
-
const
|
| 1382 |
-
const got=m?(m[1]||m[2]||m[3]):null;
|
| 1383 |
const correct=got===ansLetter;
|
| 1384 |
const hasExp=o.length>60;
|
| 1385 |
const judge=await judgeWithLLM(f.q,o,{
|
|
@@ -1402,7 +1459,7 @@ async function makeReasoning(rng,seed){
|
|
| 1402 |
const gsm=await fetchGSM8K(seed);
|
| 1403 |
if(gsm){
|
| 1404 |
return{id:"RS",category:"reasoning",label:`GSM8K #${gsm.offset}: ${gsm.question.slice(0,50)}…`,
|
| 1405 |
-
hfSource:gsm.source,hfOffset:gsm.offset,targetMs:50000,ref:REF.reasoning,
|
| 1406 |
prompt:`Risolvi il problema matematico passo per passo.\n\n${gsm.question}\n\nFormato risposta: **#### N** (N = numero intero).`,
|
| 1407 |
verify:async(o)=>{
|
| 1408 |
const m=o.match(/####\s*(-?\d[\d,]*)/)||o.match(/(?:answer|risposta|risultato|total|totale|result)[:\s=]+(-?\d[\d,]+)/i)||o.match(/\*\*(-?\d[\d,]+)\*\*/);
|
|
|
|
| 314 |
// e ritenta 1 volta se la risposta è troppo corta (cold start / timeout transitorio)
|
| 315 |
async function callAgentWithRetry(task, timeoutMs) {
|
| 316 |
const hint = FSH[task.category];
|
| 317 |
+
// Append FSH hint so the agent knows the expected output format.
|
| 318 |
const goal = hint
|
| 319 |
+
? `${task.prompt}\n\n---\n📌 FORMATO RISPOSTA ATTESO:\n${hint}`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
: task.prompt;
|
| 321 |
const a = await callAgent(goal, timeoutMs);
|
| 322 |
// Infrastructure failures are not model answers and must not be retried/scored.
|
| 323 |
if (a.failed) return a;
|
| 324 |
+
|
| 325 |
+
// Reasoning retry: one hidden-contract recalculation before the final attempt.
|
| 326 |
+
const reasoningFailure = reasoningRetryFailure(task, a.output);
|
| 327 |
+
if (reasoningFailure) {
|
| 328 |
+
const repairGoal = `${goal}\n\n---\n🔁 CONTROLLO DI CALCOLO:\nLa risposta numerica finale non è stata accettata dal controllo deterministico (${reasoningFailure}). Ricalcola il problema indipendentemente, verifica ogni passaggio e restituisci una sola risposta finale nel formato #### N. Non assumere il risultato precedente.`;
|
| 329 |
+
if(!F_JSON) process.stdout.write(` ⟳ retry reasoning (${reasoningFailure})... `);
|
| 330 |
+
await new Promise(r => setTimeout(r, 1000));
|
| 331 |
+
const a2 = await callAgent(repairGoal, timeoutMs);
|
| 332 |
+
if (!F_JSON) process.stdout.write("ok\\n");
|
| 333 |
+
const secondFailure = reasoningRetryFailure(task, a2.output);
|
| 334 |
+
if (!a2.failed && !secondFailure) return a2;
|
| 335 |
+
return (a2.output||"").length > (a.output||"").length ? a2 : a;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// Retry once if response is too short (transient failure / cold start).
|
| 339 |
if ((a.output||"").length < 40 && !(a.output||"").includes("TIMEOUT")) {
|
| 340 |
if(!F_JSON) process.stdout.write(" ⟳ retry (risposta vuota)... ");
|
| 341 |
await new Promise(r => setTimeout(r, 4000));
|
| 342 |
const a2 = await callAgent(goal, timeoutMs);
|
| 343 |
+
if (!F_JSON) process.stdout.write("ok\\n");
|
| 344 |
return (a2.output||"").length > (a.output||"").length ? a2 : a;
|
| 345 |
}
|
| 346 |
return a;
|
|
|
|
| 545 |
});
|
| 546 |
}
|
| 547 |
function extractCode(out,langs){
|
| 548 |
+
// Accetta fence canonicali su nuova riga e fence compatti con contenuto sulla
|
| 549 |
+
// stessa riga; il backend validator usa la stessa tolleranza.
|
| 550 |
+
const re=/```([A-Za-z0-9_+#.-]*)[ \t]*(?:\r?\n|[ \t]+)([\s\S]*?)```/g; let best={code:"",len:0};
|
| 551 |
for(const m of out.matchAll(re)){
|
| 552 |
const l=(m[1]||"").toLowerCase(),c=m[2].trimEnd();
|
| 553 |
if((langs.includes(l)||langs.includes("*"))&&c.length>best.len) best={code:c,len:c.length};
|
| 554 |
}
|
| 555 |
return best.code;
|
| 556 |
}
|
| 557 |
+
|
| 558 |
+
// MMLU parser canonico — mantenere sincronizzato con backend/benchmarks/validators.py.
|
| 559 |
+
// La priorità replica il contratto v5, esteso con ANSWER/FINAL per risposte
|
| 560 |
+
// strutturate; il fallback isolato resta compatibile con il runner storico.
|
| 561 |
+
function extractMMLUChoice(out){
|
| 562 |
+
const text=String(out||"");
|
| 563 |
+
const m=text.match(/\*\*\(?([A-D])\)?\*\*|\b(?:ANSWER|FINAL(?:\s+ANSWER)?|RISPOSTA|CHOICE|SCELTA)\b\s*(?:IS|=|:)\s*[*`_\[(]*([A-D])[*`_\])]*|\bRisposta[:\s]+([A-D])\b|\b([A-D])\)/i)
|
| 564 |
+
|| text.match(/\b([A-D])\b/i);
|
| 565 |
+
return m ? String(m[1]||m[2]||m[3]||m[4]||"").toUpperCase() || null : null;
|
| 566 |
+
}
|
| 567 |
// ── LLM-as-Judge (GROQ/Cerebras semantic scoring) ────────────────────────────
|
| 568 |
const _JUDGE_CACHE = new Map();
|
| 569 |
|
|
|
|
| 655 |
return{question:row.question,expectedAnswer:parseInt(m[1].replace(/,/g,"")),
|
| 656 |
offset:hfOffset(seed,HF_GSM8K),source:"openai/gsm8k"};
|
| 657 |
}
|
| 658 |
+
function extractReasoningNumbers(text){
|
| 659 |
+
const source=String(text||"");
|
| 660 |
+
const explicit=[...source.matchAll(/(?:####|final\s+answer|answer|risposta|risultato|result|total|totale)\s*[:=]?\s*(-?\d[\d,]*(?:\.\d+)?)/gi)]
|
| 661 |
+
.map(m=>Number(m[1].replace(/,/g,"")));
|
| 662 |
+
if(explicit.length)return explicit;
|
| 663 |
+
const bold=[...source.matchAll(/\*\*\s*(-?\d[\d,]*(?:\.\d+)?)\s*\*\*/g)]
|
| 664 |
+
.map(m=>Number(m[1].replace(/,/g,"")));
|
| 665 |
+
if(bold.length)return bold;
|
| 666 |
+
const lines=[...source.matchAll(/^\s*(-?\d[\d,]*(?:\.\d+)?)\s*$/gm)]
|
| 667 |
+
.map(m=>Number(m[1].replace(/,/g,"")));
|
| 668 |
+
return lines.length?lines.slice(-1):[];
|
| 669 |
+
}
|
| 670 |
+
function reasoningRetryFailure(task,output){
|
| 671 |
+
if(task.category!=="reasoning"||!Number.isFinite(task.expectedAnswer))return null;
|
| 672 |
+
const candidates=extractReasoningNumbers(output);
|
| 673 |
+
const distinct=[...new Set(candidates)];
|
| 674 |
+
if(!distinct.length)return"answer_missing";
|
| 675 |
+
if(distinct.length>1)return"calculation_conflict";
|
| 676 |
+
return distinct[0]!==task.expectedAnswer?"wrong_numeric_answer":null;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
async function fetchBBH(seed){
|
| 680 |
const row=await hfRow("lukaemon/bbh","logical_deduction_three_objects","test",hfOffset(seed,HF_BBH,3571));
|
| 681 |
if(!row?.input)return null;
|
|
|
|
| 715 |
if(!res.body)return{ok:false,output:"",engine,ttfa,toolCalls,durationMs:Date.now()-t0,failed:true,failureReason:"stream body mancante"};
|
| 716 |
const dec=new TextDecoder(); let buf="";
|
| 717 |
const rd=res.body.getReader();
|
| 718 |
+
let stopStream=false;
|
| 719 |
+
const processSSEData=(raw)=>{
|
| 720 |
+
if(!raw||raw==="[DONE]")return raw==="[DONE]";
|
| 721 |
+
try{
|
| 722 |
+
const ev=JSON.parse(raw),now=Date.now()-t0;
|
| 723 |
+
// Backend emits `event`; tolerate legacy/proxy clients that emit `type`.
|
| 724 |
+
const eventType=ev.type||ev.event||"";
|
| 725 |
+
if(eventType==="tool_use"){toolCalls++;ttfa=ttfa??now;}
|
| 726 |
+
else if(eventType==="step_done"){ttfa=ttfa??now;}
|
| 727 |
+
else if(eventType==="text_chunk"&&!done){
|
| 728 |
+
const value=ev.token??ev.content;
|
| 729 |
+
if(value!==undefined&&value!==null){
|
| 730 |
+
const chunk=String(value),trimmed=chunk.trim();
|
|
|
|
| 731 |
if(!(trimmed.startsWith("{")&&trimmed.endsWith("}")))out+=chunk;
|
| 732 |
ttfa=ttfa??now;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
}
|
| 734 |
+
} else if(eventType==="task_error"){
|
| 735 |
+
failed=true;failureReason=String(ev.error||"task_error");done=true;return true;
|
| 736 |
+
} else if(eventType==="task_done"){
|
| 737 |
+
const rawResult=ev.result??ev.output??ev.answer??ev.content??"";
|
| 738 |
+
const result=typeof rawResult==="string"?rawResult:String(rawResult||"");
|
| 739 |
+
if(ev.success===false||result.startsWith("[LLM_UNAVAILABLE]")||result.includes("tutti i provider configurati sono falliti")){
|
| 740 |
+
failed=true;failureReason=result||"provider_unavailable";engine=ev.engine??engine;done=true;return true;
|
| 741 |
+
}
|
| 742 |
+
if(result&&result.length>(out||"").length)out=result;
|
| 743 |
+
engine=ev.engine??engine;done=true;return true;
|
| 744 |
+
}
|
| 745 |
+
}catch{}
|
| 746 |
+
return false;
|
| 747 |
+
};
|
| 748 |
+
while(!stopStream){
|
| 749 |
+
const{done:d,value}=await rd.read();
|
| 750 |
+
if(d){
|
| 751 |
+
buf+=dec.decode();
|
| 752 |
+
break;
|
| 753 |
+
}
|
| 754 |
+
buf+=dec.decode(value,{stream:true});
|
| 755 |
+
const lines=buf.split(/\r?\n/); buf=lines.pop()??"";
|
| 756 |
+
for(const ln of lines){
|
| 757 |
+
if(!ln.startsWith("data:"))continue;
|
| 758 |
+
if(processSSEData(ln.slice(5).trim())){stopStream=true;break;}
|
| 759 |
}
|
| 760 |
}
|
| 761 |
+
// A proxy may close after a final unterminated data line.
|
| 762 |
+
if(!stopStream&&buf.trim().startsWith("data:"))processSSEData(buf.trim().slice(5).trim());
|
| 763 |
rd.cancel?.();
|
| 764 |
}catch(e){
|
| 765 |
if(taskId){
|
|
|
|
| 1416 |
hfSource:"cais/mmlu/"+subject,hfOffset:offset,targetMs:30000,ref:REF.mmlu,
|
| 1417 |
prompt:"Domanda di informatica a scelta multipla:\n\n"+row.question+"\n\nA) "+row.choices[0]+"\nB) "+row.choices[1]+"\nC) "+row.choices[2]+"\nD) "+row.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
|
| 1418 |
verify:async function(o){
|
| 1419 |
+
const got=extractMMLUChoice(o);
|
|
|
|
| 1420 |
const correct=got===ansLetter;
|
| 1421 |
const hasExp=o.length>60&&/perch|quindi|perche|because|quindi|questo|in quanto/i.test(o);
|
| 1422 |
const judge=await judgeWithLLM(row.question,o,{
|
|
|
|
| 1436 |
hfSource:"local-cs-fundamentals",targetMs:30000,ref:REF.mmlu,
|
| 1437 |
prompt:"Domanda di informatica a scelta multipla:\n\n"+f.q+"\n\nA) "+f.choices[0]+"\nB) "+f.choices[1]+"\nC) "+f.choices[2]+"\nD) "+f.choices[3]+"\n\nRispondi con la lettera **(A/B/C/D)** e spiega brevemente il ragionamento.",
|
| 1438 |
verify:async function(o){
|
| 1439 |
+
const got=extractMMLUChoice(o);
|
|
|
|
| 1440 |
const correct=got===ansLetter;
|
| 1441 |
const hasExp=o.length>60;
|
| 1442 |
const judge=await judgeWithLLM(f.q,o,{
|
|
|
|
| 1459 |
const gsm=await fetchGSM8K(seed);
|
| 1460 |
if(gsm){
|
| 1461 |
return{id:"RS",category:"reasoning",label:`GSM8K #${gsm.offset}: ${gsm.question.slice(0,50)}…`,
|
| 1462 |
+
hfSource:gsm.source,hfOffset:gsm.offset,expectedAnswer:gsm.expectedAnswer,targetMs:50000,ref:REF.reasoning,
|
| 1463 |
prompt:`Risolvi il problema matematico passo per passo.\n\n${gsm.question}\n\nFormato risposta: **#### N** (N = numero intero).`,
|
| 1464 |
verify:async(o)=>{
|
| 1465 |
const m=o.match(/####\s*(-?\d[\d,]*)/)||o.match(/(?:answer|risposta|risultato|total|totale|result)[:\s=]+(-?\d[\d,]+)/i)||o.match(/\*\*(-?\d[\d,]+)\*\*/);
|
benchmarks/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic benchmark validators and observe-only model discovery."""
|
| 2 |
+
|
| 3 |
+
from .model_watch_adapter import (
|
| 4 |
+
CatalogResult,
|
| 5 |
+
CatalogStatus,
|
| 6 |
+
GeminiModelsAdapter,
|
| 7 |
+
ObserveOnlyModelsAdapter,
|
| 8 |
+
ProfileScan,
|
| 9 |
+
ProviderProfile,
|
| 10 |
+
scan_profiles,
|
| 11 |
+
)
|
| 12 |
+
from .shadow_telemetry import (
|
| 13 |
+
shadow_enabled,
|
| 14 |
+
validate_and_record_shadow,
|
| 15 |
+
)
|
| 16 |
+
from .validators import (
|
| 17 |
+
ValidationResult,
|
| 18 |
+
validate_coding_output,
|
| 19 |
+
validate_coding_retry,
|
| 20 |
+
validate_mmlu_output,
|
| 21 |
+
validate_reasoning_output,
|
| 22 |
+
validate_reasoning_retry,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"CatalogResult",
|
| 27 |
+
"CatalogStatus",
|
| 28 |
+
"GeminiModelsAdapter",
|
| 29 |
+
"ObserveOnlyModelsAdapter",
|
| 30 |
+
"ProfileScan",
|
| 31 |
+
"ProviderProfile",
|
| 32 |
+
"scan_profiles",
|
| 33 |
+
"ValidationResult",
|
| 34 |
+
"shadow_enabled",
|
| 35 |
+
"validate_and_record_shadow",
|
| 36 |
+
"validate_coding_output",
|
| 37 |
+
"validate_coding_retry",
|
| 38 |
+
"validate_mmlu_output",
|
| 39 |
+
"validate_reasoning_output",
|
| 40 |
+
"validate_reasoning_retry",
|
| 41 |
+
]
|
benchmarks/model_watch_adapter.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Observe-only model catalog adapters.
|
| 2 |
+
|
| 3 |
+
This module performs discovery only. It never updates ai_providers, selects a
|
| 4 |
+
fallback, or persists credentials. Callers can use CatalogResult as an audit
|
| 5 |
+
record and decide separately whether a later approval/apply phase is allowed.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field, replace
|
| 10 |
+
import asyncio
|
| 11 |
+
from enum import Enum
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any, Mapping, Optional
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CatalogStatus(str, Enum):
|
| 20 |
+
AVAILABLE = "available"
|
| 21 |
+
UNAUTHORIZED = "unauthorized"
|
| 22 |
+
FORBIDDEN = "forbidden"
|
| 23 |
+
RATE_LIMITED = "rate_limited"
|
| 24 |
+
PROVIDER_ERROR = "provider_error"
|
| 25 |
+
TIMEOUT = "timeout"
|
| 26 |
+
NETWORK_ERROR = "network_error"
|
| 27 |
+
MALFORMED = "malformed"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class ProviderProfile:
|
| 32 |
+
provider: str
|
| 33 |
+
profile: str
|
| 34 |
+
base_url: str
|
| 35 |
+
api_key: str
|
| 36 |
+
default_model: str
|
| 37 |
+
auth_mode: str = "bearer" # bearer | query_key
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class CatalogResult:
|
| 42 |
+
provider: str
|
| 43 |
+
profile: str
|
| 44 |
+
status: CatalogStatus
|
| 45 |
+
http_status: Optional[int] = None
|
| 46 |
+
models: tuple[str, ...] = ()
|
| 47 |
+
default_available: Optional[bool] = None
|
| 48 |
+
retry_after_seconds: Optional[int] = None
|
| 49 |
+
detail: str = ""
|
| 50 |
+
checked_url: str = ""
|
| 51 |
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def should_auto_apply(self) -> bool:
|
| 55 |
+
"""Observe-only invariant: this adapter can never authorize a write."""
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
def as_audit_record(self) -> dict[str, Any]:
|
| 59 |
+
return {
|
| 60 |
+
"provider": self.provider,
|
| 61 |
+
"profile": self.profile,
|
| 62 |
+
"status": self.status.value,
|
| 63 |
+
"http_status": self.http_status,
|
| 64 |
+
"model_count": len(self.models),
|
| 65 |
+
"default_available": self.default_available,
|
| 66 |
+
"retry_after_seconds": self.retry_after_seconds,
|
| 67 |
+
"detail": self.detail[:240],
|
| 68 |
+
"checked_url": self.checked_url,
|
| 69 |
+
"metadata": dict(self.metadata),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
_RETRY_AFTER_SECONDS = re.compile(r"^\s*(\d+)\s*$")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def models_url(base_url: str) -> str:
|
| 77 |
+
"""Normalize common OpenAI-compatible base URLs to a models endpoint."""
|
| 78 |
+
value = base_url.rstrip("/")
|
| 79 |
+
for suffix in ("/chat/completions", "/completions"):
|
| 80 |
+
if value.endswith(suffix):
|
| 81 |
+
value = value[: -len(suffix)]
|
| 82 |
+
if not value.endswith("/models"):
|
| 83 |
+
value += "/models"
|
| 84 |
+
return value
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _retry_after(headers: Mapping[str, str]) -> Optional[int]:
|
| 88 |
+
raw = headers.get("retry-after") or headers.get("Retry-After")
|
| 89 |
+
if not raw:
|
| 90 |
+
return None
|
| 91 |
+
match = _RETRY_AFTER_SECONDS.match(raw)
|
| 92 |
+
return int(match.group(1)) if match else None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _safe_detail(response: httpx.Response) -> str:
|
| 96 |
+
"""Return bounded provider detail without authorization headers or secrets."""
|
| 97 |
+
try:
|
| 98 |
+
payload = response.json()
|
| 99 |
+
if isinstance(payload, Mapping):
|
| 100 |
+
for key in ("error", "message", "detail", "code"):
|
| 101 |
+
value = payload.get(key)
|
| 102 |
+
if value is not None:
|
| 103 |
+
return str(value)[:240]
|
| 104 |
+
return json.dumps(payload, ensure_ascii=True)[:240]
|
| 105 |
+
except Exception:
|
| 106 |
+
return response.text[:240]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _models_from_gemini_payload(payload: Any) -> tuple[str, ...] | None:
|
| 110 |
+
"""Parse Gemini's native {models: [{name: 'models/<id>'}]} payload."""
|
| 111 |
+
if not isinstance(payload, Mapping) or not isinstance(payload.get("models"), list):
|
| 112 |
+
return None
|
| 113 |
+
models: list[str] = []
|
| 114 |
+
for item in payload["models"]:
|
| 115 |
+
if not isinstance(item, Mapping):
|
| 116 |
+
continue
|
| 117 |
+
name = item.get("name") or item.get("baseModelId")
|
| 118 |
+
if isinstance(name, str) and name.strip():
|
| 119 |
+
normalized = name.strip()
|
| 120 |
+
if normalized.startswith("models/"):
|
| 121 |
+
normalized = normalized[len("models/"):]
|
| 122 |
+
models.append(normalized)
|
| 123 |
+
return tuple(dict.fromkeys(models))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _models_from_payload(payload: Any) -> tuple[str, ...] | None:
|
| 127 |
+
if isinstance(payload, Mapping):
|
| 128 |
+
items = payload.get("data")
|
| 129 |
+
else:
|
| 130 |
+
items = payload
|
| 131 |
+
if not isinstance(items, list):
|
| 132 |
+
return None
|
| 133 |
+
models: list[str] = []
|
| 134 |
+
for item in items:
|
| 135 |
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str) and item["id"].strip():
|
| 136 |
+
models.append(item["id"].strip())
|
| 137 |
+
return tuple(dict.fromkeys(models))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@dataclass(frozen=True)
|
| 141 |
+
class ProfileScan:
|
| 142 |
+
"""Results plus profiles skipped because their provider returned 429."""
|
| 143 |
+
results: tuple[CatalogResult, ...]
|
| 144 |
+
skipped_rate_limited: tuple[CatalogResult, ...] = ()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class ObserveOnlyModelsAdapter:
|
| 148 |
+
"""Fetch a provider catalog and classify the result; never mutates state."""
|
| 149 |
+
|
| 150 |
+
def __init__(self, *, timeout_seconds: float = 8.0, client: httpx.AsyncClient | None = None):
|
| 151 |
+
self.timeout_seconds = timeout_seconds
|
| 152 |
+
self._client = client
|
| 153 |
+
|
| 154 |
+
async def list_models(self, profile: ProviderProfile) -> CatalogResult:
|
| 155 |
+
url = models_url(profile.base_url)
|
| 156 |
+
headers = {"Accept": "application/json"}
|
| 157 |
+
params: dict[str, str] = {}
|
| 158 |
+
if profile.auth_mode == "query_key":
|
| 159 |
+
params["key"] = profile.api_key
|
| 160 |
+
else:
|
| 161 |
+
headers["Authorization"] = f"Bearer {profile.api_key}"
|
| 162 |
+
|
| 163 |
+
owns_client = self._client is None
|
| 164 |
+
client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
|
| 165 |
+
try:
|
| 166 |
+
response = await client.get(url, headers=headers, params=params)
|
| 167 |
+
status = response.status_code
|
| 168 |
+
if status == 401:
|
| 169 |
+
return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
|
| 170 |
+
if status == 403:
|
| 171 |
+
return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
|
| 172 |
+
if status == 429:
|
| 173 |
+
return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
|
| 174 |
+
if 500 <= status <= 599:
|
| 175 |
+
return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
|
| 176 |
+
if status != 200:
|
| 177 |
+
return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
|
| 178 |
+
try:
|
| 179 |
+
payload = response.json()
|
| 180 |
+
except (ValueError, json.JSONDecodeError):
|
| 181 |
+
return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
|
| 182 |
+
models = _models_from_payload(payload)
|
| 183 |
+
if models is None:
|
| 184 |
+
return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing data list")
|
| 185 |
+
return CatalogResult(
|
| 186 |
+
provider=profile.provider,
|
| 187 |
+
profile=profile.profile,
|
| 188 |
+
status=CatalogStatus.AVAILABLE,
|
| 189 |
+
http_status=status,
|
| 190 |
+
models=models,
|
| 191 |
+
default_available=profile.default_model in models,
|
| 192 |
+
checked_url=url,
|
| 193 |
+
detail="catalog fetched",
|
| 194 |
+
)
|
| 195 |
+
except httpx.TimeoutException as exc:
|
| 196 |
+
return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
|
| 197 |
+
except httpx.RequestError as exc:
|
| 198 |
+
return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
|
| 199 |
+
finally:
|
| 200 |
+
if owns_client:
|
| 201 |
+
await client.aclose()
|
| 202 |
+
|
| 203 |
+
@staticmethod
|
| 204 |
+
def _result(profile: ProviderProfile, url: str, status: CatalogStatus, response: httpx.Response, *, detail: str = "") -> CatalogResult:
|
| 205 |
+
return CatalogResult(
|
| 206 |
+
provider=profile.provider,
|
| 207 |
+
profile=profile.profile,
|
| 208 |
+
status=status,
|
| 209 |
+
http_status=response.status_code,
|
| 210 |
+
retry_after_seconds=_retry_after(response.headers) if status == CatalogStatus.RATE_LIMITED else None,
|
| 211 |
+
detail=detail or _safe_detail(response),
|
| 212 |
+
checked_url=url,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class GeminiModelsAdapter(ObserveOnlyModelsAdapter):
|
| 217 |
+
"""Observe-only adapter for Gemini's native ``models`` catalog."""
|
| 218 |
+
|
| 219 |
+
async def list_models(self, profile: ProviderProfile) -> CatalogResult:
|
| 220 |
+
url = models_url(profile.base_url)
|
| 221 |
+
headers = {"Accept": "application/json"}
|
| 222 |
+
params = {"key": profile.api_key}
|
| 223 |
+
owns_client = self._client is None
|
| 224 |
+
client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds)
|
| 225 |
+
try:
|
| 226 |
+
response = await client.get(url, headers=headers, params=params)
|
| 227 |
+
status = response.status_code
|
| 228 |
+
if status == 401:
|
| 229 |
+
return self._result(profile, url, CatalogStatus.UNAUTHORIZED, response)
|
| 230 |
+
if status == 403:
|
| 231 |
+
return self._result(profile, url, CatalogStatus.FORBIDDEN, response)
|
| 232 |
+
if status == 429:
|
| 233 |
+
return self._result(profile, url, CatalogStatus.RATE_LIMITED, response)
|
| 234 |
+
if 500 <= status <= 599:
|
| 235 |
+
return self._result(profile, url, CatalogStatus.PROVIDER_ERROR, response)
|
| 236 |
+
if status != 200:
|
| 237 |
+
return self._result(profile, url, CatalogStatus.NETWORK_ERROR, response)
|
| 238 |
+
try:
|
| 239 |
+
payload = response.json()
|
| 240 |
+
except (ValueError, json.JSONDecodeError):
|
| 241 |
+
return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="invalid JSON")
|
| 242 |
+
models = _models_from_gemini_payload(payload)
|
| 243 |
+
if models is None:
|
| 244 |
+
return self._result(profile, url, CatalogStatus.MALFORMED, response, detail="missing models list")
|
| 245 |
+
return CatalogResult(
|
| 246 |
+
provider=profile.provider,
|
| 247 |
+
profile=profile.profile,
|
| 248 |
+
status=CatalogStatus.AVAILABLE,
|
| 249 |
+
http_status=status,
|
| 250 |
+
models=models,
|
| 251 |
+
default_available=profile.default_model in models,
|
| 252 |
+
checked_url=url,
|
| 253 |
+
detail="Gemini catalog fetched",
|
| 254 |
+
metadata={"catalog_format": "gemini_native"},
|
| 255 |
+
)
|
| 256 |
+
except httpx.TimeoutException as exc:
|
| 257 |
+
return CatalogResult(profile.provider, profile.profile, CatalogStatus.TIMEOUT, detail=str(exc)[:240], checked_url=url)
|
| 258 |
+
except httpx.RequestError as exc:
|
| 259 |
+
return CatalogResult(profile.provider, profile.profile, CatalogStatus.NETWORK_ERROR, detail=str(exc)[:240], checked_url=url)
|
| 260 |
+
finally:
|
| 261 |
+
if owns_client:
|
| 262 |
+
await client.aclose()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
async def scan_profiles(
|
| 266 |
+
profiles: list[ProviderProfile],
|
| 267 |
+
*,
|
| 268 |
+
adapter: ObserveOnlyModelsAdapter | None = None,
|
| 269 |
+
) -> ProfileScan:
|
| 270 |
+
"""Scan a mixed pool and isolate 429 profiles without blocking healthy ones."""
|
| 271 |
+
adapter = adapter or ObserveOnlyModelsAdapter()
|
| 272 |
+
results = await asyncio.gather(*(adapter.list_models(profile) for profile in profiles))
|
| 273 |
+
skipped = tuple(
|
| 274 |
+
replace(result, metadata={"skipped": True, "skip_reason": "rate_limited"})
|
| 275 |
+
for result in results
|
| 276 |
+
if result.status == CatalogStatus.RATE_LIMITED
|
| 277 |
+
)
|
| 278 |
+
active = tuple(result for result in results if result.status != CatalogStatus.RATE_LIMITED)
|
| 279 |
+
return ProfileScan(results=active, skipped_rate_limited=skipped)
|
benchmarks/shadow_telemetry.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fail-open shadow telemetry for benchmark output validators.
|
| 2 |
+
|
| 3 |
+
Shadow mode records validator outcomes only. It never changes the answer, retry
|
| 4 |
+
budget, provider selection, or benchmark score. Raw model output is deliberately
|
| 5 |
+
not persisted; only length and normalized validator evidence are stored.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import threading
|
| 15 |
+
from typing import Any, Mapping, Optional
|
| 16 |
+
|
| 17 |
+
from .validators import ValidationResult, validate_coding_output, validate_mmlu_output
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
_ENABLED_VALUES = frozenset({"1", "true", "yes", "on"})
|
| 21 |
+
_WRITE_LOCK = threading.Lock()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def shadow_enabled() -> bool:
|
| 25 |
+
return os.getenv("BENCHMARK_SHADOW_MODE", "0").strip().lower() in _ENABLED_VALUES
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def infer_benchmark_category(goal: Any) -> Optional[str]:
|
| 29 |
+
"""Infer only the two supported benchmark categories from explicit markers."""
|
| 30 |
+
|
| 31 |
+
text = str(goal or "")
|
| 32 |
+
lowered = text.lower()
|
| 33 |
+
if "mmlu" in lowered or "scelta multipla" in lowered or "a/b/c/d" in lowered:
|
| 34 |
+
return "mmlu"
|
| 35 |
+
if "code_correct" in lowered or "typescript" in lowered or "```typescript" in lowered:
|
| 36 |
+
return "coding"
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _safe_metadata(metadata: Optional[Mapping[str, Any]]) -> dict[str, Any]:
|
| 41 |
+
allowed = {
|
| 42 |
+
"provider",
|
| 43 |
+
"model",
|
| 44 |
+
"profile",
|
| 45 |
+
"attempt",
|
| 46 |
+
"latency_ms",
|
| 47 |
+
"first_token_ms",
|
| 48 |
+
"task_id",
|
| 49 |
+
"source",
|
| 50 |
+
}
|
| 51 |
+
safe: dict[str, Any] = {}
|
| 52 |
+
for key in allowed:
|
| 53 |
+
value = (metadata or {}).get(key)
|
| 54 |
+
if value is None:
|
| 55 |
+
continue
|
| 56 |
+
if isinstance(value, (str, int, float, bool)):
|
| 57 |
+
safe[key] = value
|
| 58 |
+
else:
|
| 59 |
+
safe[key] = str(value)[:120]
|
| 60 |
+
return safe
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _evidence_for_log(result: ValidationResult) -> dict[str, Any]:
|
| 64 |
+
evidence: dict[str, Any] = {}
|
| 65 |
+
for key, value in result.evidence.items():
|
| 66 |
+
if key == "source_length":
|
| 67 |
+
evidence[key] = value
|
| 68 |
+
elif key in {"candidates", "distinct_candidates", "required_symbols", "missing_symbols", "declarations", "fence_count", "languages", "extraction", "significant_lines", "correct", "expected", "has_import_or_export", "has_syntax_tokens"}:
|
| 69 |
+
evidence[key] = value
|
| 70 |
+
return evidence
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _log_path() -> Path:
|
| 74 |
+
return Path(os.getenv("BENCHMARK_SHADOW_LOG_PATH", "/tmp/baida98-benchmark-shadow.jsonl"))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _append_event(event: dict[str, Any]) -> None:
|
| 78 |
+
path = _log_path()
|
| 79 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
with _WRITE_LOCK:
|
| 81 |
+
with path.open("a", encoding="utf-8") as handle:
|
| 82 |
+
handle.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def validate_and_record_shadow(
|
| 86 |
+
*,
|
| 87 |
+
goal: Any,
|
| 88 |
+
answer: Any,
|
| 89 |
+
metadata: Optional[Mapping[str, Any]] = None,
|
| 90 |
+
) -> Optional[ValidationResult]:
|
| 91 |
+
"""Validate and record a supported benchmark response in fail-open shadow mode."""
|
| 92 |
+
|
| 93 |
+
if not shadow_enabled():
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
category = infer_benchmark_category(goal)
|
| 97 |
+
if category is None:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
if category == "mmlu":
|
| 101 |
+
result = validate_mmlu_output(answer)
|
| 102 |
+
validator = "mmlu_v1"
|
| 103 |
+
else:
|
| 104 |
+
result = validate_coding_output(answer)
|
| 105 |
+
validator = "coding_v1"
|
| 106 |
+
|
| 107 |
+
text = answer if isinstance(answer, str) else str(answer or "")
|
| 108 |
+
event = {
|
| 109 |
+
"schema_version": 1,
|
| 110 |
+
"event": "benchmark_shadow_validation",
|
| 111 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 112 |
+
"category": category,
|
| 113 |
+
"validator": validator,
|
| 114 |
+
"valid": result.valid,
|
| 115 |
+
"failure_code": result.failure_code,
|
| 116 |
+
"response_chars": len(text),
|
| 117 |
+
"evidence": _evidence_for_log(result),
|
| 118 |
+
"metadata": _safe_metadata(metadata),
|
| 119 |
+
}
|
| 120 |
+
try:
|
| 121 |
+
_append_event(event)
|
| 122 |
+
except Exception:
|
| 123 |
+
# Shadow telemetry must never break the agent loop.
|
| 124 |
+
return result
|
| 125 |
+
return result
|
benchmarks/validators.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic validators for benchmark outputs.
|
| 2 |
+
|
| 3 |
+
The validators in this module deliberately do not call an LLM or a provider. They
|
| 4 |
+
only normalize an output when the evidence is unambiguous and otherwise return a
|
| 5 |
+
stable failure code that the retry layer can act on.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from typing import Any, Iterable, Mapping, Optional
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
_MMLU_LETTERS = frozenset("ABCD")
|
| 17 |
+
_MMLU_EXPLICIT = re.compile(
|
| 18 |
+
r"\b(?:answer|答案|risposta|final(?:\s+answer)?|choice|scelta)\b\s*"
|
| 19 |
+
r"(?:is|=|:)\s*[*`_\[\(]*([A-D])[*`_\]\)]*",
|
| 20 |
+
re.IGNORECASE,
|
| 21 |
+
)
|
| 22 |
+
_MMLU_MARKED = re.compile(r"(?:^|\n|\s)(?:\(?([A-D])\)?)[\.:\)](?:\s|$)", re.IGNORECASE)
|
| 23 |
+
_MMLU_ISOLATED = re.compile(r"(?<![A-Za-z])([A-D])(?![A-Za-z])", re.IGNORECASE)
|
| 24 |
+
|
| 25 |
+
_CODE_FENCE = re.compile(
|
| 26 |
+
r"```\s*([A-Za-z0-9_+#.-]*)\s*\n?(.*?)```", re.IGNORECASE | re.DOTALL
|
| 27 |
+
)
|
| 28 |
+
_CODE_JSON_KEYS = ("code", "typescript", "source", "implementation")
|
| 29 |
+
_TS_DECLARATION = re.compile(
|
| 30 |
+
r"\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][\w$]*)",
|
| 31 |
+
re.MULTILINE,
|
| 32 |
+
)
|
| 33 |
+
_TS_IMPORT_EXPORT = re.compile(r"\b(?:import|export)\b")
|
| 34 |
+
_TS_SYNTAX_TOKENS = re.compile(r"[{}();]|=>|:\s*[A-Za-z_$][\w$<>,\[\]| ]*")
|
| 35 |
+
_PLACEHOLDER = re.compile(r"\b(?:TODO|TBD|your implementation|implement here)\b", re.IGNORECASE)
|
| 36 |
+
_REASONING_EXPLICIT = re.compile(
|
| 37 |
+
r"(?:####|final\s+answer|answer|risposta|risultato|result|total|totale)\s*[:=]?\s*"
|
| 38 |
+
r"(-?\d[\d,]*(?:\.\d+)?)",
|
| 39 |
+
re.IGNORECASE,
|
| 40 |
+
)
|
| 41 |
+
_REASONING_BOLD = re.compile(r"\*\*\s*(-?\d[\d,]*(?:\.\d+)?)\s*\*\*")
|
| 42 |
+
_REASONING_LINE_NUMBER = re.compile(r"(?m)^\s*(-?\d[\d,]*(?:\.\d+)?)\s*$")
|
| 43 |
+
_REASONING_FAILURES = frozenset({"answer_missing", "wrong_numeric_answer", "calculation_conflict"})
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass(frozen=True)
|
| 47 |
+
class ValidationResult:
|
| 48 |
+
"""Stable validator result consumed by shadow mode and retry logic."""
|
| 49 |
+
|
| 50 |
+
valid: bool
|
| 51 |
+
normalized: Optional[str] = None
|
| 52 |
+
failure_code: Optional[str] = None
|
| 53 |
+
evidence: dict[str, Any] = field(default_factory=dict)
|
| 54 |
+
repair_hint: Optional[str] = None
|
| 55 |
+
|
| 56 |
+
def as_dict(self) -> dict[str, Any]:
|
| 57 |
+
return {
|
| 58 |
+
"valid": self.valid,
|
| 59 |
+
"normalized": self.normalized,
|
| 60 |
+
"failure_code": self.failure_code,
|
| 61 |
+
"evidence": self.evidence,
|
| 62 |
+
"repair_hint": self.repair_hint,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _failure(code: str, *, evidence: Optional[dict[str, Any]] = None, hint: str = "") -> ValidationResult:
|
| 67 |
+
return ValidationResult(
|
| 68 |
+
valid=False,
|
| 69 |
+
failure_code=code,
|
| 70 |
+
evidence=evidence or {},
|
| 71 |
+
repair_hint=hint or None,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _success(normalized: str, *, evidence: Optional[dict[str, Any]] = None) -> ValidationResult:
|
| 76 |
+
return ValidationResult(valid=True, normalized=normalized, evidence=evidence or {})
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _clean_text(raw: Any) -> str:
|
| 80 |
+
if raw is None:
|
| 81 |
+
return ""
|
| 82 |
+
if isinstance(raw, str):
|
| 83 |
+
return raw.strip()
|
| 84 |
+
return str(raw).strip()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _mmlu_candidates(text: str) -> list[str]:
|
| 88 |
+
"""Return candidates in evidence order, preserving duplicates for ambiguity checks."""
|
| 89 |
+
explicit = [m.group(1).upper() for m in _MMLU_EXPLICIT.finditer(text)]
|
| 90 |
+
if explicit:
|
| 91 |
+
return explicit
|
| 92 |
+
marked = [m.group(1).upper() for m in _MMLU_MARKED.finditer(text)]
|
| 93 |
+
if marked:
|
| 94 |
+
return marked
|
| 95 |
+
return [m.group(1).upper() for m in _MMLU_ISOLATED.finditer(text)]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def validate_mmlu_output(raw: Any, *, expected: Optional[str] = None) -> ValidationResult:
|
| 99 |
+
"""Validate a multiple-choice answer without guessing from explanation prose.
|
| 100 |
+
|
| 101 |
+
Accepted outputs contain one unambiguous A/B/C/D choice. Explicit labels such
|
| 102 |
+
as ``ANSWER: C`` have priority over marked choices and isolated letters. If
|
| 103 |
+
multiple distinct candidates are present, the result is ambiguous and fails.
|
| 104 |
+
``expected`` is optional and is only used to expose correctness in evidence; it
|
| 105 |
+
never changes the parsing result.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
text = _clean_text(raw)
|
| 109 |
+
if not text:
|
| 110 |
+
return _failure(
|
| 111 |
+
"answer_missing",
|
| 112 |
+
hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
candidates = _mmlu_candidates(text)
|
| 116 |
+
distinct = sorted(set(candidates))
|
| 117 |
+
evidence: dict[str, Any] = {
|
| 118 |
+
"candidates": candidates,
|
| 119 |
+
"distinct_candidates": distinct,
|
| 120 |
+
"source_length": len(text),
|
| 121 |
+
}
|
| 122 |
+
if expected is not None:
|
| 123 |
+
normalized_expected = str(expected).strip().upper()
|
| 124 |
+
evidence["expected"] = normalized_expected
|
| 125 |
+
if normalized_expected in _MMLU_LETTERS:
|
| 126 |
+
evidence["correct"] = len(distinct) == 1 and distinct[0] == normalized_expected
|
| 127 |
+
|
| 128 |
+
if not candidates:
|
| 129 |
+
return _failure(
|
| 130 |
+
"answer_missing",
|
| 131 |
+
evidence=evidence,
|
| 132 |
+
hint="Return exactly one canonical choice using ANSWER: A, B, C, or D.",
|
| 133 |
+
)
|
| 134 |
+
if len(distinct) != 1 or distinct[0] not in _MMLU_LETTERS:
|
| 135 |
+
return _failure(
|
| 136 |
+
"answer_ambiguous",
|
| 137 |
+
evidence=evidence,
|
| 138 |
+
hint="Remove competing choices and return one letter: A, B, C, or D.",
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return _success(distinct[0], evidence=evidence)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _extract_code(raw: Any) -> tuple[Optional[str], str, dict[str, Any]]:
|
| 145 |
+
"""Extract code from a TypeScript fence or a JSON envelope."""
|
| 146 |
+
|
| 147 |
+
text = _clean_text(raw)
|
| 148 |
+
if not text:
|
| 149 |
+
return None, "none", {"source_length": 0}
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
decoded = json.loads(text)
|
| 153 |
+
except (TypeError, json.JSONDecodeError):
|
| 154 |
+
decoded = None
|
| 155 |
+
if isinstance(decoded, Mapping):
|
| 156 |
+
for key in _CODE_JSON_KEYS:
|
| 157 |
+
value = decoded.get(key)
|
| 158 |
+
if isinstance(value, str) and value.strip():
|
| 159 |
+
return value.strip(), f"json:{key}", {"source_length": len(text)}
|
| 160 |
+
|
| 161 |
+
fences = _CODE_FENCE.findall(text)
|
| 162 |
+
if fences:
|
| 163 |
+
typed = [body.strip() for language, body in fences if language.lower() in {"ts", "typescript"}]
|
| 164 |
+
if typed:
|
| 165 |
+
return max(typed, key=len), "fence:typescript", {"fence_count": len(fences)}
|
| 166 |
+
return None, "fence:wrong-language", {"languages": [language.lower() for language, _ in fences]}
|
| 167 |
+
|
| 168 |
+
return None, "none", {"source_length": len(text)}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _normalize_symbols(required_symbols: Iterable[str]) -> list[str]:
|
| 172 |
+
return [symbol.strip() for symbol in required_symbols if str(symbol).strip()]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _parse_numeric_token(value: str) -> int | float:
|
| 176 |
+
normalized = value.replace(",", "").strip()
|
| 177 |
+
number = float(normalized) if "." in normalized else int(normalized)
|
| 178 |
+
return number
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _reasoning_candidates(text: str) -> tuple[list[int | float], str]:
|
| 182 |
+
"""Extract answer candidates conservatively, preferring explicit final markers."""
|
| 183 |
+
explicit = [_parse_numeric_token(match.group(1)) for match in _REASONING_EXPLICIT.finditer(text)]
|
| 184 |
+
if explicit:
|
| 185 |
+
return explicit, "explicit"
|
| 186 |
+
bold = [_parse_numeric_token(match.group(1)) for match in _REASONING_BOLD.finditer(text)]
|
| 187 |
+
if bold:
|
| 188 |
+
return bold, "bold"
|
| 189 |
+
lines = [_parse_numeric_token(match.group(1)) for match in _REASONING_LINE_NUMBER.finditer(text)]
|
| 190 |
+
if lines:
|
| 191 |
+
return lines[-1:], "final_line"
|
| 192 |
+
return [], "none"
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def validate_reasoning_output(raw: Any, *, expected: Optional[int | float] = None) -> ValidationResult:
|
| 196 |
+
"""Validate a numeric reasoning answer without calling an LLM.
|
| 197 |
+
|
| 198 |
+
Explicit final markers have priority over intermediate arithmetic. Multiple
|
| 199 |
+
distinct explicit answers are classified as a conflict rather than guessed.
|
| 200 |
+
"""
|
| 201 |
+
text = _clean_text(raw)
|
| 202 |
+
if not text:
|
| 203 |
+
return _failure(
|
| 204 |
+
"answer_missing",
|
| 205 |
+
hint="Show the calculation and finish with #### N, where N is the final integer.",
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
candidates, source = _reasoning_candidates(text)
|
| 209 |
+
distinct = list(dict.fromkeys(candidates))
|
| 210 |
+
evidence: dict[str, Any] = {
|
| 211 |
+
"candidates": candidates,
|
| 212 |
+
"distinct_candidates": distinct,
|
| 213 |
+
"source": source,
|
| 214 |
+
"source_length": len(text),
|
| 215 |
+
}
|
| 216 |
+
if expected is not None:
|
| 217 |
+
try:
|
| 218 |
+
normalized_expected = _parse_numeric_token(str(expected))
|
| 219 |
+
evidence["expected"] = normalized_expected
|
| 220 |
+
except ValueError:
|
| 221 |
+
normalized_expected = expected
|
| 222 |
+
|
| 223 |
+
if not candidates:
|
| 224 |
+
return _failure(
|
| 225 |
+
"answer_missing",
|
| 226 |
+
evidence=evidence,
|
| 227 |
+
hint="Show the calculation and finish with #### N, where N is the final integer.",
|
| 228 |
+
)
|
| 229 |
+
if len(distinct) > 1:
|
| 230 |
+
return _failure(
|
| 231 |
+
"calculation_conflict",
|
| 232 |
+
evidence=evidence,
|
| 233 |
+
hint="Recalculate the final value and provide exactly one final numeric answer.",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
normalized = distinct[0]
|
| 237 |
+
if expected is not None and normalized != normalized_expected:
|
| 238 |
+
evidence["correct"] = False
|
| 239 |
+
return _failure(
|
| 240 |
+
"wrong_numeric_answer",
|
| 241 |
+
evidence=evidence,
|
| 242 |
+
hint="Recheck every arithmetic step and return the corrected final number.",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
evidence["correct"] = True if expected is not None else None
|
| 246 |
+
return _success(str(normalized), evidence=evidence)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def validate_reasoning_retry(
|
| 250 |
+
goal: Any,
|
| 251 |
+
raw: Any,
|
| 252 |
+
*,
|
| 253 |
+
expected: Optional[int | float] = None,
|
| 254 |
+
is_last_attempt: bool,
|
| 255 |
+
) -> Optional[ValidationResult]:
|
| 256 |
+
"""Return a reasoning failure only when a non-final numeric retry is warranted."""
|
| 257 |
+
if is_last_attempt or not any(marker in str(goal or "").lower() for marker in ("reasoning", "gsm8k", "risolvi il problema matematico")):
|
| 258 |
+
return None
|
| 259 |
+
result = validate_reasoning_output(raw, expected=expected)
|
| 260 |
+
return result if result.failure_code in _REASONING_FAILURES else None
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
_CODING_RETRY_FAILURES = frozenset({
|
| 264 |
+
"code_missing",
|
| 265 |
+
"code_wrong_language",
|
| 266 |
+
"code_empty",
|
| 267 |
+
"code_placeholder",
|
| 268 |
+
"required_symbol_missing",
|
| 269 |
+
"code_syntax_suspect",
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def is_typescript_goal(goal: Any) -> bool:
|
| 274 |
+
lowered = str(goal or "").lower()
|
| 275 |
+
return any(marker in lowered for marker in ("code_correct", "typescript", "```ts", "```typescript"))
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def validate_coding_retry(goal: Any, raw: Any, *, is_last_attempt: bool) -> Optional[ValidationResult]:
|
| 279 |
+
"""Return the failed result only when a non-final TypeScript retry is warranted."""
|
| 280 |
+
|
| 281 |
+
if is_last_attempt or not is_typescript_goal(goal):
|
| 282 |
+
return None
|
| 283 |
+
result = validate_coding_output(raw)
|
| 284 |
+
return result if result.failure_code in _CODING_RETRY_FAILURES else None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def validate_coding_output(
|
| 288 |
+
raw: Any,
|
| 289 |
+
*,
|
| 290 |
+
required_symbols: Iterable[str] = (),
|
| 291 |
+
min_significant_lines: int = 1,
|
| 292 |
+
reject_placeholders: bool = True,
|
| 293 |
+
) -> ValidationResult:
|
| 294 |
+
"""Validate extraction and minimum structural quality of TypeScript output.
|
| 295 |
+
|
| 296 |
+
This is intentionally a contract validator, not a compiler. Syntax checks are
|
| 297 |
+
conservative and deterministic; full compilation remains a separate isolated
|
| 298 |
+
integration test because it depends on the repository's TypeScript toolchain.
|
| 299 |
+
"""
|
| 300 |
+
|
| 301 |
+
code, source, extraction_evidence = _extract_code(raw)
|
| 302 |
+
if code is None:
|
| 303 |
+
failure = "code_wrong_language" if source == "fence:wrong-language" else "code_missing"
|
| 304 |
+
return _failure(
|
| 305 |
+
failure,
|
| 306 |
+
evidence=extraction_evidence | {"extraction": source},
|
| 307 |
+
hint="Return exactly one non-empty ```typescript code block.",
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
significant_lines = [line for line in code.splitlines() if line.strip() and not line.strip().startswith("//")]
|
| 311 |
+
evidence: dict[str, Any] = extraction_evidence | {
|
| 312 |
+
"extraction": source,
|
| 313 |
+
"significant_lines": len(significant_lines),
|
| 314 |
+
"has_import_or_export": bool(_TS_IMPORT_EXPORT.search(code)),
|
| 315 |
+
"has_syntax_tokens": bool(_TS_SYNTAX_TOKENS.search(code)),
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
if not significant_lines or len(significant_lines) < max(1, min_significant_lines):
|
| 319 |
+
return _failure(
|
| 320 |
+
"code_empty",
|
| 321 |
+
evidence=evidence,
|
| 322 |
+
hint="Provide a complete non-empty TypeScript implementation.",
|
| 323 |
+
)
|
| 324 |
+
if reject_placeholders and _PLACEHOLDER.search(code):
|
| 325 |
+
return _failure(
|
| 326 |
+
"code_placeholder",
|
| 327 |
+
evidence=evidence,
|
| 328 |
+
hint="Replace TODO/TBD placeholders with executable TypeScript.",
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
declarations = {match.group(1) for match in _TS_DECLARATION.finditer(code)}
|
| 332 |
+
required = _normalize_symbols(required_symbols)
|
| 333 |
+
missing = [symbol for symbol in required if symbol not in declarations and not re.search(rf"\b{re.escape(symbol)}\b", code)]
|
| 334 |
+
evidence["declarations"] = sorted(declarations)
|
| 335 |
+
evidence["required_symbols"] = required
|
| 336 |
+
evidence["missing_symbols"] = missing
|
| 337 |
+
if missing:
|
| 338 |
+
return _failure(
|
| 339 |
+
"required_symbol_missing",
|
| 340 |
+
evidence=evidence,
|
| 341 |
+
hint=f"Implement and expose the required symbols: {', '.join(missing)}.",
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if not _TS_SYNTAX_TOKENS.search(code):
|
| 345 |
+
return _failure(
|
| 346 |
+
"code_syntax_suspect",
|
| 347 |
+
evidence=evidence,
|
| 348 |
+
hint="Return syntactically structured TypeScript with declarations and delimiters.",
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
return _success(code, evidence=evidence)
|
models/ai_client.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
| 16 |
import asyncio
|
| 17 |
import json
|
| 18 |
import os
|
|
|
|
| 19 |
import time as _time_mod
|
| 20 |
from dataclasses import dataclass
|
| 21 |
from typing import AsyncIterator, Optional, List, Tuple
|
|
@@ -62,7 +63,7 @@ _PROVIDER_DEFS = [
|
|
| 62 |
# tier 0 — free tier veloce e affidabile
|
| 63 |
{"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "openai/gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
|
| 64 |
{"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
|
| 65 |
-
{"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.
|
| 66 |
# tier 1 — free tier con rate limit più stretti
|
| 67 |
{"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "openai/gpt-oss-20b:free", "tier": 1, "purpose": "coding"},
|
| 68 |
{"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
|
|
@@ -261,12 +262,26 @@ class AIClient:
|
|
| 261 |
state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
|
| 262 |
failures = int(state.get("failures", 0)) + 1
|
| 263 |
severe = any(token in message for token in ("401", "403"))
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
if failures >= threshold:
|
| 266 |
-
cooldown = 900.0 if severe else
|
| 267 |
state["open_until"] = _time_mod.monotonic() + cooldown
|
| 268 |
state["failures"] = failures
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
|
| 271 |
"""Return one rotated, healthy profile per provider endpoint group."""
|
| 272 |
groups: dict[tuple[str, str], list[ProviderConfig]] = {}
|
|
@@ -286,6 +301,7 @@ class AIClient:
|
|
| 286 |
self,
|
| 287 |
purpose: str,
|
| 288 |
excluded: set[str] | None = None,
|
|
|
|
| 289 |
) -> list[ProviderConfig]:
|
| 290 |
"""Select one healthy profile per provider, prioritizing the target purpose.
|
| 291 |
|
|
@@ -294,8 +310,9 @@ class AIClient:
|
|
| 294 |
fallback. This prevents retry storms against an exhausted pool.
|
| 295 |
"""
|
| 296 |
excluded = excluded or set()
|
|
|
|
| 297 |
candidates = [
|
| 298 |
-
provider for provider in
|
| 299 |
if provider.name not in excluded and self._is_available(provider)
|
| 300 |
]
|
| 301 |
candidates.sort(key=lambda provider: (
|
|
@@ -454,7 +471,7 @@ class AIClient:
|
|
| 454 |
|
| 455 |
# Un profilo sano per provider: se l’intero pool primario è in rate
|
| 456 |
# limit, il fallback passa automaticamente al provider successivo.
|
| 457 |
-
providers = self._inter_provider_fallback_pool("stream")
|
| 458 |
attempted: list[str] = []
|
| 459 |
for provider in providers:
|
| 460 |
attempted.append(provider.name)
|
|
|
|
| 16 |
import asyncio
|
| 17 |
import json
|
| 18 |
import os
|
| 19 |
+
import re
|
| 20 |
import time as _time_mod
|
| 21 |
from dataclasses import dataclass
|
| 22 |
from typing import AsyncIterator, Optional, List, Tuple
|
|
|
|
| 63 |
# tier 0 — free tier veloce e affidabile
|
| 64 |
{"name": "groq", "env_key": "GROQ_API_KEY", "base_url": "https://api.groq.com/openai/v1", "model_env": "GROQ_MODEL", "default_model": "openai/gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
|
| 65 |
{"name": "cerebras", "env_key": "CEREBRAS_API_KEY", "base_url": "https://api.cerebras.ai/v1", "model_env": "CEREBRAS_MODEL", "default_model": "gpt-oss-120b", "tier": 0, "purpose": "reasoning"},
|
| 66 |
+
{"name": "sambanova", "env_key": "SAMBANOVA_API_KEY", "base_url": "https://api.sambanova.ai/v1", "model_env": "SAMBANOVA_MODEL", "default_model": "DeepSeek-V3.1", "tier": 0, "purpose": "reasoning"},
|
| 67 |
# tier 1 — free tier con rate limit più stretti
|
| 68 |
{"name": "openrouter", "env_key": "OPENROUTER_API_KEY", "base_url": "https://openrouter.ai/api/v1", "model_env": "OPENROUTER_MODEL","default_model": "openai/gpt-oss-20b:free", "tier": 1, "purpose": "coding"},
|
| 69 |
{"name": "hf_router", "env_key": "HF_TOKEN", "base_url": "https://router.huggingface.co/v1", "model_env": "HF_MODEL", "default_model": "Qwen/Qwen2.5-Coder-32B-Instruct", "tier": 1, "purpose": "coding"},
|
|
|
|
| 262 |
state = self._breaker.setdefault(provider.identity, {"failures": 0, "open_until": 0.0})
|
| 263 |
failures = int(state.get("failures", 0)) + 1
|
| 264 |
severe = any(token in message for token in ("401", "403"))
|
| 265 |
+
quota_limited = any(token in message for token in ("429", "rate limit", "quota"))
|
| 266 |
+
# A quota/rate-limit response is deterministic: retrying the same
|
| 267 |
+
# profile immediately only creates a storm. Open that profile on the
|
| 268 |
+
# first signal and let the provider pool move to another provider.
|
| 269 |
+
threshold = 1 if severe or quota_limited else self._breaker_threshold
|
| 270 |
if failures >= threshold:
|
| 271 |
+
cooldown = 900.0 if severe else self._rate_limit_cooldown_seconds(message) if quota_limited else self._breaker_cooldown_s
|
| 272 |
state["open_until"] = _time_mod.monotonic() + cooldown
|
| 273 |
state["failures"] = failures
|
| 274 |
|
| 275 |
+
@staticmethod
|
| 276 |
+
def _rate_limit_cooldown_seconds(message: str) -> float:
|
| 277 |
+
"""Return a provider reset-aware cooldown, never shorter than 15 min."""
|
| 278 |
+
reset_match = re.search(r"x-ratelimit-reset[^0-9]*(\d{10,13})", message, re.IGNORECASE)
|
| 279 |
+
if reset_match:
|
| 280 |
+
reset_value = float(reset_match.group(1))
|
| 281 |
+
reset_epoch = reset_value / 1000.0 if reset_value > 10_000_000_000 else reset_value
|
| 282 |
+
return max(900.0, reset_epoch - _time_mod.time())
|
| 283 |
+
return 900.0
|
| 284 |
+
|
| 285 |
def _execution_pool(self, providers: list[ProviderConfig], purpose: str) -> list[ProviderConfig]:
|
| 286 |
"""Return one rotated, healthy profile per provider endpoint group."""
|
| 287 |
groups: dict[tuple[str, str], list[ProviderConfig]] = {}
|
|
|
|
| 301 |
self,
|
| 302 |
purpose: str,
|
| 303 |
excluded: set[str] | None = None,
|
| 304 |
+
providers: list[ProviderConfig] | None = None,
|
| 305 |
) -> list[ProviderConfig]:
|
| 306 |
"""Select one healthy profile per provider, prioritizing the target purpose.
|
| 307 |
|
|
|
|
| 310 |
fallback. This prevents retry storms against an exhausted pool.
|
| 311 |
"""
|
| 312 |
excluded = excluded or set()
|
| 313 |
+
source = self.providers if providers is None else providers
|
| 314 |
candidates = [
|
| 315 |
+
provider for provider in source
|
| 316 |
if provider.name not in excluded and self._is_available(provider)
|
| 317 |
]
|
| 318 |
candidates.sort(key=lambda provider: (
|
|
|
|
| 471 |
|
| 472 |
# Un profilo sano per provider: se l’intero pool primario è in rate
|
| 473 |
# limit, il fallback passa automaticamente al provider successivo.
|
| 474 |
+
providers = self._inter_provider_fallback_pool("stream", providers=providers)
|
| 475 |
attempted: list[str] = []
|
| 476 |
for provider in providers:
|
| 477 |
attempted.append(provider.name)
|
tests/test_ai_client_provider_unavailability.py
CHANGED
|
@@ -26,6 +26,12 @@ class _ClientWithFailingProviders(AIClient):
|
|
| 26 |
]
|
| 27 |
self._client_cache = {}
|
| 28 |
self._rr_indices = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def _client_for(self, _provider):
|
| 31 |
return _FailingClient()
|
|
@@ -38,7 +44,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
|
|
| 38 |
with self.assertRaises(ProviderUnavailableError) as raised:
|
| 39 |
await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
|
| 40 |
|
| 41 |
-
self.
|
| 42 |
self.assertNotIn("api_key", str(raised.exception).lower())
|
| 43 |
|
| 44 |
async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
|
|
@@ -48,7 +54,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
|
|
| 48 |
async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
|
| 49 |
pass
|
| 50 |
|
| 51 |
-
self.
|
| 52 |
self.assertNotIn("api_key", str(raised.exception).lower())
|
| 53 |
|
| 54 |
async def test_stream_chat_expands_a_role_specific_provider_pool(self):
|
|
@@ -65,7 +71,7 @@ class ProviderUnavailableTests(unittest.IsolatedAsyncioTestCase):
|
|
| 65 |
async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
|
| 66 |
pass
|
| 67 |
|
| 68 |
-
self.
|
| 69 |
|
| 70 |
|
| 71 |
class RuntimeModelOverrideTests(unittest.TestCase):
|
|
|
|
| 26 |
]
|
| 27 |
self._client_cache = {}
|
| 28 |
self._rr_indices = {}
|
| 29 |
+
# Stato minimo richiesto dai percorsi chat/stream dopo l’introduzione
|
| 30 |
+
# del circuit breaker per profilo. Non chiama AIClient.__init__ e non
|
| 31 |
+
# carica provider o segreti dall’ambiente.
|
| 32 |
+
self._breaker = {}
|
| 33 |
+
self._breaker_threshold = 2
|
| 34 |
+
self._breaker_cooldown_s = 60.0
|
| 35 |
|
| 36 |
def _client_for(self, _provider):
|
| 37 |
return _FailingClient()
|
|
|
|
| 44 |
with self.assertRaises(ProviderUnavailableError) as raised:
|
| 45 |
await client.chat([{"role": "user", "content": "hello"}], max_tokens=8)
|
| 46 |
|
| 47 |
+
self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
|
| 48 |
self.assertNotIn("api_key", str(raised.exception).lower())
|
| 49 |
|
| 50 |
async def test_stream_chat_raises_structured_error_when_every_provider_fails(self):
|
|
|
|
| 54 |
async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
|
| 55 |
pass
|
| 56 |
|
| 57 |
+
self.assertCountEqual(raised.exception.providers, ("primary", "fallback"))
|
| 58 |
self.assertNotIn("api_key", str(raised.exception).lower())
|
| 59 |
|
| 60 |
async def test_stream_chat_expands_a_role_specific_provider_pool(self):
|
|
|
|
| 71 |
async for _ in client.stream_chat([{"role": "user", "content": "hello"}], max_tokens=8):
|
| 72 |
pass
|
| 73 |
|
| 74 |
+
self.assertCountEqual(raised.exception.providers, ("gemini-role", "nvidia"))
|
| 75 |
|
| 76 |
|
| 77 |
class RuntimeModelOverrideTests(unittest.TestCase):
|
tests/test_benchmark_validators.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from benchmarks.validators import (
|
| 4 |
+
validate_coding_output,
|
| 5 |
+
validate_coding_retry,
|
| 6 |
+
validate_mmlu_output,
|
| 7 |
+
validate_reasoning_output,
|
| 8 |
+
validate_reasoning_retry,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MMLUValidatorTests(unittest.TestCase):
|
| 13 |
+
def test_accepts_explicit_answer_with_explanation(self):
|
| 14 |
+
result = validate_mmlu_output(
|
| 15 |
+
"ANSWER: C\nPerché la complessità nel caso peggiore è quadratica.",
|
| 16 |
+
expected="C",
|
| 17 |
+
)
|
| 18 |
+
self.assertTrue(result.valid)
|
| 19 |
+
self.assertEqual(result.normalized, "C")
|
| 20 |
+
self.assertTrue(result.evidence["correct"])
|
| 21 |
+
|
| 22 |
+
def test_accepts_marked_choice(self):
|
| 23 |
+
result = validate_mmlu_output("La scelta corretta è (B). La stack segue LIFO.")
|
| 24 |
+
self.assertTrue(result.valid)
|
| 25 |
+
self.assertEqual(result.normalized, "B")
|
| 26 |
+
|
| 27 |
+
def test_accepts_single_isolated_letter(self):
|
| 28 |
+
result = validate_mmlu_output("D")
|
| 29 |
+
self.assertTrue(result.valid)
|
| 30 |
+
self.assertEqual(result.normalized, "D")
|
| 31 |
+
|
| 32 |
+
def test_accepts_final_answer_contract_used_by_retry(self):
|
| 33 |
+
result = validate_mmlu_output("Final answer: D\nThe two values overflow because both are negative.")
|
| 34 |
+
self.assertTrue(result.valid)
|
| 35 |
+
self.assertEqual(result.normalized, "D")
|
| 36 |
+
|
| 37 |
+
def test_accepts_runner_bold_contract(self):
|
| 38 |
+
result = validate_mmlu_output("**(B)** — risposta scelta")
|
| 39 |
+
self.assertTrue(result.valid)
|
| 40 |
+
self.assertEqual(result.normalized, "B")
|
| 41 |
+
|
| 42 |
+
def test_explanation_letters_do_not_override_explicit_answer(self):
|
| 43 |
+
result = validate_mmlu_output("ANSWER: A. Le opzioni B, C e D sono errate.")
|
| 44 |
+
self.assertTrue(result.valid)
|
| 45 |
+
self.assertEqual(result.normalized, "A")
|
| 46 |
+
|
| 47 |
+
def test_rejects_missing_answer(self):
|
| 48 |
+
result = validate_mmlu_output("La spiegazione descrive il concetto ma non seleziona un'opzione.")
|
| 49 |
+
self.assertFalse(result.valid)
|
| 50 |
+
self.assertEqual(result.failure_code, "answer_missing")
|
| 51 |
+
|
| 52 |
+
def test_rejects_conflicting_explicit_answers(self):
|
| 53 |
+
result = validate_mmlu_output("ANSWER: A\nFinal answer: C")
|
| 54 |
+
self.assertFalse(result.valid)
|
| 55 |
+
self.assertEqual(result.failure_code, "answer_ambiguous")
|
| 56 |
+
self.assertEqual(result.evidence["distinct_candidates"], ["A", "C"])
|
| 57 |
+
|
| 58 |
+
def test_rejects_empty_output(self):
|
| 59 |
+
result = validate_mmlu_output(None)
|
| 60 |
+
self.assertFalse(result.valid)
|
| 61 |
+
self.assertEqual(result.failure_code, "answer_missing")
|
| 62 |
+
|
| 63 |
+
def test_expected_answer_only_affects_evidence(self):
|
| 64 |
+
result = validate_mmlu_output("ANSWER: B", expected="C")
|
| 65 |
+
self.assertTrue(result.valid)
|
| 66 |
+
self.assertFalse(result.evidence["correct"])
|
| 67 |
+
self.assertEqual(result.normalized, "B")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class CodingValidatorTests(unittest.TestCase):
|
| 71 |
+
def test_accepts_typescript_fence_and_required_symbol(self):
|
| 72 |
+
output = """Ecco l'implementazione:
|
| 73 |
+
```typescript
|
| 74 |
+
export function reverseWords(value: string): string {
|
| 75 |
+
return value.trim().split(/\\s+/).reverse().join(' ');
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
"""
|
| 79 |
+
result = validate_coding_output(output, required_symbols=["reverseWords"], min_significant_lines=3)
|
| 80 |
+
self.assertTrue(result.valid)
|
| 81 |
+
self.assertIn("reverseWords", result.normalized)
|
| 82 |
+
self.assertEqual(result.evidence["missing_symbols"], [])
|
| 83 |
+
|
| 84 |
+
def test_accepts_json_envelope(self):
|
| 85 |
+
output = '{"language":"typescript","code":"export const add = (a: number, b: number): number => a + b;"}'
|
| 86 |
+
result = validate_coding_output(output, required_symbols=["add"])
|
| 87 |
+
self.assertTrue(result.valid)
|
| 88 |
+
self.assertEqual(result.evidence["extraction"], "json:code")
|
| 89 |
+
|
| 90 |
+
def test_rejects_empty_fence(self):
|
| 91 |
+
result = validate_coding_output("```typescript\n\n```")
|
| 92 |
+
self.assertFalse(result.valid)
|
| 93 |
+
self.assertEqual(result.failure_code, "code_empty")
|
| 94 |
+
|
| 95 |
+
def test_rejects_missing_code_block(self):
|
| 96 |
+
result = validate_coding_output("La soluzione è implementata nel testo seguente, ma il codice non è incluso.")
|
| 97 |
+
self.assertFalse(result.valid)
|
| 98 |
+
self.assertEqual(result.failure_code, "code_missing")
|
| 99 |
+
|
| 100 |
+
def test_rejects_wrong_language_fence(self):
|
| 101 |
+
result = validate_coding_output("```python\ndef add(a, b): return a + b\n```")
|
| 102 |
+
self.assertFalse(result.valid)
|
| 103 |
+
self.assertEqual(result.failure_code, "code_wrong_language")
|
| 104 |
+
|
| 105 |
+
def test_rejects_required_symbol_missing(self):
|
| 106 |
+
result = validate_coding_output(
|
| 107 |
+
"```ts\nexport function subtract(a: number, b: number): number { return a - b; }\n```",
|
| 108 |
+
required_symbols=["add"],
|
| 109 |
+
)
|
| 110 |
+
self.assertFalse(result.valid)
|
| 111 |
+
self.assertEqual(result.failure_code, "required_symbol_missing")
|
| 112 |
+
self.assertEqual(result.evidence["missing_symbols"], ["add"])
|
| 113 |
+
|
| 114 |
+
def test_repaired_typescript_output_passes_contract(self):
|
| 115 |
+
result = validate_coding_output(
|
| 116 |
+
"```typescript\nexport function add(a: number, b: number): number {\n return a + b;\n}\n```",
|
| 117 |
+
required_symbols=["add"],
|
| 118 |
+
min_significant_lines=3,
|
| 119 |
+
)
|
| 120 |
+
self.assertTrue(result.valid)
|
| 121 |
+
self.assertIsNone(result.failure_code)
|
| 122 |
+
|
| 123 |
+
def test_retry_is_requested_for_missing_typescript_before_last_attempt(self):
|
| 124 |
+
result = validate_coding_retry(
|
| 125 |
+
"code_correct: implementa TypeScript",
|
| 126 |
+
"La spiegazione non contiene codice.",
|
| 127 |
+
is_last_attempt=False,
|
| 128 |
+
)
|
| 129 |
+
self.assertIsNotNone(result)
|
| 130 |
+
self.assertEqual(result.failure_code, "code_missing")
|
| 131 |
+
|
| 132 |
+
def test_retry_is_not_requested_on_last_attempt(self):
|
| 133 |
+
result = validate_coding_retry(
|
| 134 |
+
"code_correct: implementa TypeScript",
|
| 135 |
+
"La spiegazione non contiene codice.",
|
| 136 |
+
is_last_attempt=True,
|
| 137 |
+
)
|
| 138 |
+
self.assertIsNone(result)
|
| 139 |
+
|
| 140 |
+
def test_retry_is_not_requested_for_non_coding_goal(self):
|
| 141 |
+
result = validate_coding_retry(
|
| 142 |
+
"Scrivi una spiegazione concettuale",
|
| 143 |
+
"La spiegazione non contiene codice.",
|
| 144 |
+
is_last_attempt=False,
|
| 145 |
+
)
|
| 146 |
+
self.assertIsNone(result)
|
| 147 |
+
|
| 148 |
+
def test_retry_is_not_requested_for_valid_typescript(self):
|
| 149 |
+
result = validate_coding_retry(
|
| 150 |
+
"code_correct: implementa TypeScript",
|
| 151 |
+
"```typescript\nexport const add = (a: number, b: number): number => a + b;\n```",
|
| 152 |
+
is_last_attempt=False,
|
| 153 |
+
)
|
| 154 |
+
self.assertIsNone(result)
|
| 155 |
+
|
| 156 |
+
def test_rejects_placeholder_implementation(self):
|
| 157 |
+
result = validate_coding_output(
|
| 158 |
+
"```typescript\nexport function add(a: number, b: number): number {\n // TODO implement here\n return 0;\n}\n```",
|
| 159 |
+
required_symbols=["add"],
|
| 160 |
+
)
|
| 161 |
+
self.assertFalse(result.valid)
|
| 162 |
+
self.assertEqual(result.failure_code, "code_placeholder")
|
| 163 |
+
|
| 164 |
+
def test_rejects_non_typescript_prose_inside_fence(self):
|
| 165 |
+
result = validate_coding_output("```typescript\nThis is only explanatory prose.\n```")
|
| 166 |
+
self.assertFalse(result.valid)
|
| 167 |
+
self.assertEqual(result.failure_code, "code_syntax_suspect")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class ReasoningValidatorTests(unittest.TestCase):
|
| 171 |
+
def test_accepts_gsm8k_contract_with_thousands_separator(self):
|
| 172 |
+
result = validate_reasoning_output(
|
| 173 |
+
"Somma i valori: 100 + 125 = 225.\n#### 225",
|
| 174 |
+
expected=225,
|
| 175 |
+
)
|
| 176 |
+
self.assertTrue(result.valid)
|
| 177 |
+
self.assertEqual(result.normalized, "225")
|
| 178 |
+
self.assertTrue(result.evidence["correct"])
|
| 179 |
+
|
| 180 |
+
def test_accepts_labeled_final_answer(self):
|
| 181 |
+
result = validate_reasoning_output("I passaggi portano al totale. Final answer: 2,250", expected=2250)
|
| 182 |
+
self.assertTrue(result.valid)
|
| 183 |
+
self.assertEqual(result.normalized, "2250")
|
| 184 |
+
|
| 185 |
+
def test_classifies_wrong_numeric_answer(self):
|
| 186 |
+
result = validate_reasoning_output("Calcolo completo. #### 250", expected=225)
|
| 187 |
+
self.assertFalse(result.valid)
|
| 188 |
+
self.assertEqual(result.failure_code, "wrong_numeric_answer")
|
| 189 |
+
self.assertFalse(result.evidence["correct"])
|
| 190 |
+
|
| 191 |
+
def test_classifies_missing_numeric_answer(self):
|
| 192 |
+
result = validate_reasoning_output("La spiegazione termina senza un numero finale.", expected=225)
|
| 193 |
+
self.assertFalse(result.valid)
|
| 194 |
+
self.assertEqual(result.failure_code, "answer_missing")
|
| 195 |
+
|
| 196 |
+
def test_classifies_conflicting_explicit_answers(self):
|
| 197 |
+
result = validate_reasoning_output("#### 250\nFinal answer: 225", expected=225)
|
| 198 |
+
self.assertFalse(result.valid)
|
| 199 |
+
self.assertEqual(result.failure_code, "calculation_conflict")
|
| 200 |
+
self.assertEqual(result.evidence["distinct_candidates"], [250, 225])
|
| 201 |
+
|
| 202 |
+
def test_reasoning_retry_is_requested_for_wrong_answer_before_last_attempt(self):
|
| 203 |
+
result = validate_reasoning_retry(
|
| 204 |
+
"reasoning GSM8K: risolvi il problema",
|
| 205 |
+
"#### 250",
|
| 206 |
+
expected=225,
|
| 207 |
+
is_last_attempt=False,
|
| 208 |
+
)
|
| 209 |
+
self.assertIsNotNone(result)
|
| 210 |
+
self.assertEqual(result.failure_code, "wrong_numeric_answer")
|
| 211 |
+
|
| 212 |
+
def test_reasoning_retry_is_not_requested_on_last_attempt(self):
|
| 213 |
+
result = validate_reasoning_retry(
|
| 214 |
+
"reasoning GSM8K: risolvi il problema",
|
| 215 |
+
"#### 250",
|
| 216 |
+
expected=225,
|
| 217 |
+
is_last_attempt=True,
|
| 218 |
+
)
|
| 219 |
+
self.assertIsNone(result)
|
| 220 |
+
|
| 221 |
+
def test_reasoning_retry_is_not_requested_for_non_reasoning_goal(self):
|
| 222 |
+
result = validate_reasoning_retry(
|
| 223 |
+
"Implementa un componente TypeScript",
|
| 224 |
+
"#### 250",
|
| 225 |
+
expected=225,
|
| 226 |
+
is_last_attempt=False,
|
| 227 |
+
)
|
| 228 |
+
self.assertIsNone(result)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
unittest.main()
|
tests/test_model_watch_adapter.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
|
| 5 |
+
from benchmarks.model_watch_adapter import (
|
| 6 |
+
CatalogStatus,
|
| 7 |
+
GeminiModelsAdapter,
|
| 8 |
+
ObserveOnlyModelsAdapter,
|
| 9 |
+
ProviderProfile,
|
| 10 |
+
models_url,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ModelWatchAdapterTests(unittest.IsolatedAsyncioTestCase):
|
| 15 |
+
def profile(self, **overrides):
|
| 16 |
+
values = {
|
| 17 |
+
"provider": "groq",
|
| 18 |
+
"profile": "A",
|
| 19 |
+
"base_url": "https://api.example.test/openai/v1",
|
| 20 |
+
"api_key": "secret-not-logged",
|
| 21 |
+
"default_model": "openai/gpt-oss-120b",
|
| 22 |
+
}
|
| 23 |
+
values.update(overrides)
|
| 24 |
+
return ProviderProfile(**values)
|
| 25 |
+
|
| 26 |
+
def adapter(self, handler):
|
| 27 |
+
transport = httpx.MockTransport(handler)
|
| 28 |
+
return ObserveOnlyModelsAdapter(client=httpx.AsyncClient(transport=transport))
|
| 29 |
+
|
| 30 |
+
async def test_catalog_available_and_default_present(self):
|
| 31 |
+
async def handler(request):
|
| 32 |
+
self.assertEqual(request.url.path, "/openai/v1/models")
|
| 33 |
+
self.assertEqual(request.headers["Authorization"], "Bearer secret-not-logged")
|
| 34 |
+
return httpx.Response(200, json={"data": [{"id": "openai/gpt-oss-120b"}, {"id": "other"}]})
|
| 35 |
+
|
| 36 |
+
adapter = self.adapter(handler)
|
| 37 |
+
result = await adapter.list_models(self.profile())
|
| 38 |
+
await adapter._client.aclose()
|
| 39 |
+
self.assertEqual(result.status, CatalogStatus.AVAILABLE)
|
| 40 |
+
self.assertTrue(result.default_available)
|
| 41 |
+
self.assertFalse(result.should_auto_apply)
|
| 42 |
+
self.assertEqual(result.as_audit_record()["model_count"], 2)
|
| 43 |
+
|
| 44 |
+
async def test_unauthorized_never_suggests_apply(self):
|
| 45 |
+
async def handler(_request):
|
| 46 |
+
return httpx.Response(401, json={"error": "invalid key"})
|
| 47 |
+
|
| 48 |
+
adapter = self.adapter(handler)
|
| 49 |
+
result = await adapter.list_models(self.profile())
|
| 50 |
+
await adapter._client.aclose()
|
| 51 |
+
self.assertEqual(result.status, CatalogStatus.UNAUTHORIZED)
|
| 52 |
+
self.assertEqual(result.http_status, 401)
|
| 53 |
+
self.assertFalse(result.should_auto_apply)
|
| 54 |
+
|
| 55 |
+
async def test_forbidden_is_distinct_from_unauthorized(self):
|
| 56 |
+
async def handler(_request):
|
| 57 |
+
return httpx.Response(403, json={"error": "forbidden"})
|
| 58 |
+
|
| 59 |
+
adapter = self.adapter(handler)
|
| 60 |
+
result = await adapter.list_models(self.profile())
|
| 61 |
+
await adapter._client.aclose()
|
| 62 |
+
self.assertEqual(result.status, CatalogStatus.FORBIDDEN)
|
| 63 |
+
|
| 64 |
+
async def test_rate_limit_preserves_retry_after_without_secret(self):
|
| 65 |
+
async def handler(_request):
|
| 66 |
+
return httpx.Response(
|
| 67 |
+
429,
|
| 68 |
+
headers={"Retry-After": "37"},
|
| 69 |
+
json={"error": "quota exceeded", "key": "must-not-be-recorded"},
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
adapter = self.adapter(handler)
|
| 73 |
+
result = await adapter.list_models(self.profile())
|
| 74 |
+
await adapter._client.aclose()
|
| 75 |
+
self.assertEqual(result.status, CatalogStatus.RATE_LIMITED)
|
| 76 |
+
self.assertEqual(result.retry_after_seconds, 37)
|
| 77 |
+
self.assertNotIn("must-not-be-recorded", result.detail)
|
| 78 |
+
|
| 79 |
+
async def test_server_error_is_provider_error(self):
|
| 80 |
+
async def handler(_request):
|
| 81 |
+
return httpx.Response(503, text="temporarily unavailable")
|
| 82 |
+
|
| 83 |
+
adapter = self.adapter(handler)
|
| 84 |
+
result = await adapter.list_models(self.profile())
|
| 85 |
+
await adapter._client.aclose()
|
| 86 |
+
self.assertEqual(result.status, CatalogStatus.PROVIDER_ERROR)
|
| 87 |
+
self.assertEqual(result.http_status, 503)
|
| 88 |
+
|
| 89 |
+
async def test_malformed_catalog_is_not_empty_catalog(self):
|
| 90 |
+
async def handler(_request):
|
| 91 |
+
return httpx.Response(200, json={"models": [{"id": "x"}]})
|
| 92 |
+
|
| 93 |
+
adapter = self.adapter(handler)
|
| 94 |
+
result = await adapter.list_models(self.profile())
|
| 95 |
+
await adapter._client.aclose()
|
| 96 |
+
self.assertEqual(result.status, CatalogStatus.MALFORMED)
|
| 97 |
+
self.assertIsNone(result.default_available)
|
| 98 |
+
|
| 99 |
+
async def test_timeout_is_classified(self):
|
| 100 |
+
async def handler(_request):
|
| 101 |
+
raise httpx.ReadTimeout("provider timeout")
|
| 102 |
+
|
| 103 |
+
adapter = self.adapter(handler)
|
| 104 |
+
result = await adapter.list_models(self.profile())
|
| 105 |
+
await adapter._client.aclose()
|
| 106 |
+
self.assertEqual(result.status, CatalogStatus.TIMEOUT)
|
| 107 |
+
|
| 108 |
+
async def test_query_key_auth_does_not_use_bearer(self):
|
| 109 |
+
async def handler(request):
|
| 110 |
+
self.assertEqual(request.url.params.get("key"), "secret-not-logged")
|
| 111 |
+
self.assertNotIn("authorization", request.headers)
|
| 112 |
+
return httpx.Response(200, json=[{"id": "gemini-3.6-flash"}])
|
| 113 |
+
|
| 114 |
+
adapter = self.adapter(handler)
|
| 115 |
+
result = await adapter.list_models(self.profile(auth_mode="query_key", default_model="gemini-3.6-flash"))
|
| 116 |
+
await adapter._client.aclose()
|
| 117 |
+
self.assertEqual(result.status, CatalogStatus.AVAILABLE)
|
| 118 |
+
self.assertTrue(result.default_available)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class ModelWatchUrlTests(unittest.TestCase):
|
| 122 |
+
def test_normalizes_completion_url(self):
|
| 123 |
+
self.assertEqual(models_url("https://x/v1/chat/completions"), "https://x/v1/models")
|
| 124 |
+
|
| 125 |
+
def test_keeps_existing_models_suffix(self):
|
| 126 |
+
self.assertEqual(models_url("https://x/v1/models"), "https://x/v1/models")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
unittest.main()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class GeminiModelsAdapterTests(unittest.IsolatedAsyncioTestCase):
|
| 134 |
+
def profile(self, profile="A", default_model="gemini-3.6-flash"):
|
| 135 |
+
return ProviderProfile(
|
| 136 |
+
provider="gemini",
|
| 137 |
+
profile=profile,
|
| 138 |
+
base_url="https://generativelanguage.googleapis.com/v1beta",
|
| 139 |
+
api_key="gemini-secret-not-logged",
|
| 140 |
+
default_model=default_model,
|
| 141 |
+
auth_mode="query_key",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
async def test_native_models_payload_is_parsed_and_prefix_removed(self):
|
| 145 |
+
async def handler(request):
|
| 146 |
+
self.assertEqual(request.url.params.get("key"), "gemini-secret-not-logged")
|
| 147 |
+
return httpx.Response(200, json={"models": [
|
| 148 |
+
{"name": "models/gemini-3.6-flash"},
|
| 149 |
+
{"name": "models/gemini-3.5-flash"},
|
| 150 |
+
]})
|
| 151 |
+
|
| 152 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
| 153 |
+
result = await GeminiModelsAdapter(client=client).list_models(self.profile())
|
| 154 |
+
await client.aclose()
|
| 155 |
+
self.assertEqual(result.status, CatalogStatus.AVAILABLE)
|
| 156 |
+
self.assertEqual(result.models, ("gemini-3.6-flash", "gemini-3.5-flash"))
|
| 157 |
+
self.assertTrue(result.default_available)
|
| 158 |
+
self.assertEqual(result.metadata["catalog_format"], "gemini_native")
|
| 159 |
+
|
| 160 |
+
async def test_rate_limited_gemini_profile_is_classified(self):
|
| 161 |
+
async def handler(_request):
|
| 162 |
+
return httpx.Response(429, headers={"Retry-After": "60"}, json={"error": "quota"})
|
| 163 |
+
|
| 164 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
| 165 |
+
result = await GeminiModelsAdapter(client=client).list_models(self.profile())
|
| 166 |
+
await client.aclose()
|
| 167 |
+
self.assertEqual(result.status, CatalogStatus.RATE_LIMITED)
|
| 168 |
+
self.assertEqual(result.retry_after_seconds, 60)
|
| 169 |
+
|
| 170 |
+
async def test_scan_profiles_skips_429_but_keeps_healthy_profiles(self):
|
| 171 |
+
calls = []
|
| 172 |
+
|
| 173 |
+
async def handler(request):
|
| 174 |
+
profile = request.url.params.get("profile")
|
| 175 |
+
calls.append(request.url.path)
|
| 176 |
+
if len(calls) == 1:
|
| 177 |
+
return httpx.Response(429, json={"error": "quota"})
|
| 178 |
+
return httpx.Response(200, json={"models": [{"name": "models/gemini-3.6-flash"}]})
|
| 179 |
+
|
| 180 |
+
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
| 181 |
+
adapter = GeminiModelsAdapter(client=client)
|
| 182 |
+
from benchmarks.model_watch_adapter import scan_profiles
|
| 183 |
+
scan = await scan_profiles([self.profile("A"), self.profile("B")], adapter=adapter)
|
| 184 |
+
await client.aclose()
|
| 185 |
+
self.assertEqual(len(scan.skipped_rate_limited), 1)
|
| 186 |
+
self.assertEqual(scan.skipped_rate_limited[0].metadata["skip_reason"], "rate_limited")
|
| 187 |
+
self.assertEqual(len(scan.results), 1)
|
| 188 |
+
self.assertEqual(scan.results[0].status, CatalogStatus.AVAILABLE)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
unittest.main()
|
tests/test_provider_model_defaults.py
CHANGED
|
@@ -23,6 +23,9 @@ class ProviderModelDefaultsTests(unittest.TestCase):
|
|
| 23 |
self.assertEqual(defaults["gemini"], "gemini-3.6-flash")
|
| 24 |
self.assertEqual(defaults["cerebras"], "gpt-oss-120b")
|
| 25 |
self.assertEqual(defaults["openrouter"], "openai/gpt-oss-20b:free")
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
def test_retired_provider_fallbacks_are_not_reintroduced(self):
|
| 28 |
defaults = set(self._provider_defaults().values())
|
|
@@ -30,6 +33,7 @@ class ProviderModelDefaultsTests(unittest.TestCase):
|
|
| 30 |
self.assertNotIn("gemini-2.0-flash-exp", defaults)
|
| 31 |
self.assertNotIn("llama-4-scout", defaults)
|
| 32 |
self.assertNotIn("meta-llama/llama-4-scout:free", defaults)
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
if __name__ == "__main__":
|
|
|
|
| 23 |
self.assertEqual(defaults["gemini"], "gemini-3.6-flash")
|
| 24 |
self.assertEqual(defaults["cerebras"], "gpt-oss-120b")
|
| 25 |
self.assertEqual(defaults["openrouter"], "openai/gpt-oss-20b:free")
|
| 26 |
+
self.assertEqual(defaults["sambanova"], "DeepSeek-V3.1")
|
| 27 |
+
self.assertEqual(defaults["hf_router"], "Qwen/Qwen2.5-Coder-32B-Instruct")
|
| 28 |
+
self.assertEqual(defaults["nvidia"], "nvidia/nemotron-3-ultra-550b-a55b")
|
| 29 |
|
| 30 |
def test_retired_provider_fallbacks_are_not_reintroduced(self):
|
| 31 |
defaults = set(self._provider_defaults().values())
|
|
|
|
| 33 |
self.assertNotIn("gemini-2.0-flash-exp", defaults)
|
| 34 |
self.assertNotIn("llama-4-scout", defaults)
|
| 35 |
self.assertNotIn("meta-llama/llama-4-scout:free", defaults)
|
| 36 |
+
self.assertNotIn("DeepSeek-V3.2", defaults)
|
| 37 |
|
| 38 |
|
| 39 |
if __name__ == "__main__":
|
tests/test_provider_profile_pool.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import types
|
|
|
|
| 4 |
import unittest
|
| 5 |
from unittest.mock import AsyncMock, patch
|
| 6 |
|
|
@@ -79,11 +80,30 @@ class ProviderProfilePoolTests(unittest.TestCase):
|
|
| 79 |
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "a")
|
| 80 |
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "b")
|
| 81 |
client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
|
| 82 |
-
client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
|
| 83 |
self.assertFalse(client._is_available(profiles[1]))
|
| 84 |
selected = client._execution_pool(profiles, "coding")
|
| 85 |
self.assertNotEqual(selected[0].profile, "b")
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
|
| 89 |
async def test_chat_falls_back_when_primary_pool_returns_errors(self):
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
import types
|
| 4 |
+
import time
|
| 5 |
import unittest
|
| 6 |
from unittest.mock import AsyncMock, patch
|
| 7 |
|
|
|
|
| 80 |
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "a")
|
| 81 |
self.assertEqual(client._execution_pool(profiles, "coding")[0].profile, "b")
|
| 82 |
client._record_failure(profiles[1], RuntimeError("HTTP 429 rate limit"))
|
|
|
|
| 83 |
self.assertFalse(client._is_available(profiles[1]))
|
| 84 |
selected = client._execution_pool(profiles, "coding")
|
| 85 |
self.assertNotEqual(selected[0].profile, "b")
|
| 86 |
|
| 87 |
+
def test_rate_limit_reset_opens_profile_on_first_error(self):
|
| 88 |
+
client = AIClient()
|
| 89 |
+
profile = self._profiles()[0]
|
| 90 |
+
client._record_failure(
|
| 91 |
+
profile,
|
| 92 |
+
RuntimeError("429 free-models-per-day X-RateLimit-Reset: 4102444800000"),
|
| 93 |
+
)
|
| 94 |
+
self.assertFalse(client._is_available(profile))
|
| 95 |
+
self.assertGreater(
|
| 96 |
+
client._breaker[profile.identity]["open_until"],
|
| 97 |
+
time.monotonic() + 900,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def test_all_openrouter_profiles_are_removed_from_execution_pool(self):
|
| 101 |
+
client = AIClient()
|
| 102 |
+
profiles = self._profiles()
|
| 103 |
+
for profile in profiles:
|
| 104 |
+
client._record_failure(profile, RuntimeError("HTTP 429 free-models-per-day"))
|
| 105 |
+
self.assertEqual(client._execution_pool(profiles, "coding"), [])
|
| 106 |
+
|
| 107 |
|
| 108 |
class InterProviderFallbackChatTests(unittest.IsolatedAsyncioTestCase):
|
| 109 |
async def test_chat_falls_back_when_primary_pool_returns_errors(self):
|
tests/test_shadow_telemetry.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import tempfile
|
| 5 |
+
import unittest
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
from benchmarks.shadow_telemetry import validate_and_record_shadow
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ShadowTelemetryTests(unittest.TestCase):
|
| 12 |
+
def test_disabled_mode_does_not_write_or_validate(self):
|
| 13 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 14 |
+
path = Path(directory) / "shadow.jsonl"
|
| 15 |
+
with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "0", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
|
| 16 |
+
result = validate_and_record_shadow(goal="MMLU domanda A/B/C/D", answer="ANSWER: C")
|
| 17 |
+
self.assertIsNone(result)
|
| 18 |
+
self.assertFalse(path.exists())
|
| 19 |
+
|
| 20 |
+
def test_mmlu_failure_event_is_recorded_without_raw_answer(self):
|
| 21 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 22 |
+
path = Path(directory) / "shadow.jsonl"
|
| 23 |
+
raw = "RISPOSTA SEGRETA: il testo non deve essere salvato"
|
| 24 |
+
with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "1", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
|
| 25 |
+
result = validate_and_record_shadow(
|
| 26 |
+
goal="MMLU domanda di informatica A/B/C/D",
|
| 27 |
+
answer=raw,
|
| 28 |
+
metadata={"provider": "mock", "model": "test", "secret": "must-drop"},
|
| 29 |
+
)
|
| 30 |
+
self.assertIsNotNone(result)
|
| 31 |
+
self.assertEqual(result.failure_code, "answer_missing")
|
| 32 |
+
event = json.loads(path.read_text(encoding="utf-8"))
|
| 33 |
+
self.assertEqual(event["category"], "mmlu")
|
| 34 |
+
self.assertEqual(event["failure_code"], "answer_missing")
|
| 35 |
+
self.assertEqual(event["metadata"], {"provider": "mock", "model": "test"})
|
| 36 |
+
self.assertNotIn(raw, path.read_text(encoding="utf-8"))
|
| 37 |
+
|
| 38 |
+
def test_coding_failure_event_is_recorded(self):
|
| 39 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 40 |
+
path = Path(directory) / "shadow.jsonl"
|
| 41 |
+
with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "true", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
|
| 42 |
+
result = validate_and_record_shadow(
|
| 43 |
+
goal="code_correct: implementa in TypeScript",
|
| 44 |
+
answer="Non posso includere il codice.",
|
| 45 |
+
metadata={"attempt": 1, "latency_ms": 123.4},
|
| 46 |
+
)
|
| 47 |
+
self.assertIsNotNone(result)
|
| 48 |
+
self.assertEqual(result.failure_code, "code_missing")
|
| 49 |
+
event = json.loads(path.read_text(encoding="utf-8"))
|
| 50 |
+
self.assertEqual(event["validator"], "coding_v1")
|
| 51 |
+
self.assertEqual(event["metadata"]["attempt"], 1)
|
| 52 |
+
self.assertEqual(event["metadata"]["latency_ms"], 123.4)
|
| 53 |
+
|
| 54 |
+
def test_unsupported_category_is_ignored(self):
|
| 55 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 56 |
+
path = Path(directory) / "shadow.jsonl"
|
| 57 |
+
with patch.dict(os.environ, {"BENCHMARK_SHADOW_MODE": "1", "BENCHMARK_SHADOW_LOG_PATH": str(path)}, clear=False):
|
| 58 |
+
result = validate_and_record_shadow(goal="generic task", answer="output")
|
| 59 |
+
self.assertIsNone(result)
|
| 60 |
+
self.assertFalse(path.exists())
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
unittest.main()
|