/** * apiCallRetryBlock.ts — S508: extracted from agentLoop.ts * * Esegue la chiamata API LLM con retry esponenziale (3×) e gestisce: * 1. LLM call timeout (60s per tentativo) via AbortController * 2. Rate-limit (429) + unavailable (503): retry con backoff * 3. Network/timeout failure: streamFallback + return * * Ritorna: * - { action: "ok", response: Response } → il caller prosegue con la risposta * - { action: "return" } → streamFallback già eseguito, caller deve return * * @module apiCallRetryBlock */ import { callWithFallback } from "../providerChain"; import { streamFallback } from "./streamHelpers"; import type { ApiMsg } from "./networkTools"; import { recordOutcome as recordTaskOutcome } from "../agent/SuccessRateMonitor"; import { AGENT_TOOLS } from "./toolDefinitions"; import type { StreamOptions as AgentOptions } from "../types"; import type { TaskType } from "../taskClassifier"; // ─── Interfaces ──────────────────────────────────────────────────────────────── export interface ApiCallRetryCtx { token: string; safeMsgs: ApiMsg[]; options: AgentOptions | undefined; signal: AbortSignal | undefined; loopMessages: ApiMsg[]; initialMessages: Array<{ role: string; content: string }>; realDataInjected: boolean; preferredModel: string | undefined; maxTokensBudget: number; preCls: { type: string } | null; onStatus: (msg: string) => void; onChunk: (chunk: string) => void; } export type ApiCallRetryResult = | { action: "ok"; response: Response } | { action: "return" }; // ─── Main export ─────────────────────────────────────────────────────────────── /** * Chiama il provider LLM con retry esponenziale (max 3 tentativi). * Se tutte le retry falliscono → streamFallback e ritorna action="return". * Se la chiamata riesce → ritorna action="ok" con la Response grezza. */ export async function callLlmWithRetry(ctx: ApiCallRetryCtx): Promise { const { token, safeMsgs, options, signal, loopMessages, initialMessages, realDataInjected, preferredModel, maxTokensBudget, preCls, onStatus, onChunk, } = ctx; const _API_DELAYS = [600, 1_500, 3_500]; // ms — 600 · 1500 · 3500 let _apiAttempt = 0; let res!: Response; // eslint-disable-next-line no-constant-condition while (true) { try { // LLM call timeout: 60s per attempt — prevents hanging on frozen providers const _llmCtrl = new AbortController(); const _llmTimer = setTimeout(() => _llmCtrl.abort(), 60_000); const _llmSig: AbortSignal = (typeof AbortSignal !== "undefined" && "any" in AbortSignal) ? (AbortSignal as { any(s: AbortSignal[]): AbortSignal }).any([...(signal ? [signal] : []), _llmCtrl.signal]) : _llmCtrl.signal; try { res = await callWithFallback(token, { messages: safeMsgs as unknown as import("../api").ChatMessage[], tools: AGENT_TOOLS, tool_choice: "auto", max_tokens: Math.min(options?.maxTokens ?? 8192, 8192), // Fix A (S380): temperatura task-aware — deterministico per code/math, espressivo per chat temperature: (() => { const _TASK_TEMP: Record = { code_generation: 0.15, code_fix: 0.15, debug: 0.15, math: 0.10, data: 0.30, search_and_report: 0.40, text: 0.55, fetch: 0.45, }; const _taskDefault = _TASK_TEMP[preCls?.type ?? ""] ?? 0.50; return options?.temperature !== undefined ? Math.min(options.temperature, 0.90) : _taskDefault; })(), stream: false, // S694-fix: passa preferredModel al payload — era ignorato da callWithFallback (HF routing) ...(preferredModel ? { preferredModel } : {}), }, _llmSig); } finally { clearTimeout(_llmTimer); } } catch (err) { if (err instanceof Error && err.name === "AbortError" && signal?.aborted) throw err; // user Stop if (err instanceof Error && err.name === "AbortError") { onStatus("LLM timeout (60s) — riprovo…"); } if (_apiAttempt < _API_DELAYS.length) { const delay = _API_DELAYS[_apiAttempt++]; onStatus(`Riconnessione API… (tentativo ${_apiAttempt}/${_API_DELAYS.length + 1})`); await new Promise(r => setTimeout(r, delay)); continue; } // All retries exhausted → streamFallback console.warn("[AgentLoop] callWithFallback esaurita dopo", _apiAttempt, "tentativi:", err); onStatus("Risposta diretta…"); // S255: usa loopMessages se ha dati pre-iniettati (pre-exec riuscito), altrimenti initialMessages // S375: usa loopMessages (ha il format directive + system prompt assemblato) quando pre-exec ha iniettato dati if (realDataInjected) { await streamFallback(token, loopMessages as Array<{ role: string; content: string }>, onChunk, signal, { ...options, ...(preferredModel ? { model: preferredModel } : {}), maxTokens: maxTokensBudget }); } else { await streamFallback(token, initialMessages, onChunk, signal, { ...options, ...(preferredModel ? { model: preferredModel } : {}), maxTokens: maxTokensBudget }); } // S-GAP5: retry chain esaurita → streamFallback è degradazione del servizio, NON success. // "success" qui infiava SuccessRateMonitor → iterBudget sbagliato per quel task type. try { recordTaskOutcome((preCls?.type ?? "unknown") as TaskType, "failure"); } catch { /* non-blocking */ } return { action: "return" }; } // ── Rate-limit (429) and unavailable (503): wait and retry ──────── if (!res.ok && [429, 503].includes(res.status) && _apiAttempt < _API_DELAYS.length) { // S-LIVE-3: 503 sintetico da callWithFallback ("All providers exhausted") → retry inutile, vai subito a streamFallback if (res.status === 503) { try { const _b503 = await res.clone().text(); if (_b503.includes("providers exhausted") || _b503.includes("All providers")) break; } catch { /* ignore */ } } const delay = res.status === 429 ? _API_DELAYS[_apiAttempt++] * 3 // longer backoff for rate-limit : _API_DELAYS[_apiAttempt++]; onStatus(`API ${res.status} — riprovo tra ${Math.round(delay / 1000)}s (${_apiAttempt}/3)…`); await new Promise(r => setTimeout(r, delay)); continue; } break; // success or non-retryable error — exit retry loop } return { action: "ok", response: res }; }