Skip to main content
This integration will later be available as a Tuner library (or built into Tuner directly). For now, it’s a single drop-in file.

How it works

Building your VAPI assistants per call (the inline assistant parameter, customized per customer, campaign, or experiment)? Those transient assistants have no assistantId for Tuner’s native VAPI integration to import, so instead one function call in your end-of-call-report webhook pushes each finished call to Tuner. All of them report to one Custom API agent in Tuner: transcript, tool calls, per-turn voice metrics, recording, and cost. Attach each agent’s customization (system prompt, variant, use case) as metadata, and Tuner runs your evals based on it. The integration ships in two flavors, pick the one that matches your stack:

Node / TypeScript

send_to_tuner.ts, Node 18+ (uses the built-in fetch). No npm dependencies.

Python

send_to_tuner.py, Python 3.10+. One dependency: requests.
Both behave identically: the integration never throws (any failure is logged and your webhook handler is unaffected) and it’s idempotent, so re-sending the same call is harmless.

Setup

1

Create a Tuner account

Sign up at Tuner.
2

Create a "Custom API" agent

In Tuner, create a new agent and choose the provider Custom API. This is the agent your VAPI calls will show up under.
3

Add the file to your server

The whole integration is a single self-contained file with no dependencies beyond your language’s basics. Create send_to_tuner.ts (Node) or send_to_tuner.py (Python) next to your VAPI server and paste in the full source below, switch tabs for Node or Python and use the copy button in the code block’s top-right corner.
/**
 * Send your VAPI calls to Tuner
 * ==============================
 * Drop this file next to your VAPI server and call `sendCallToTuner(message)`
 * from inside your `end-of-call-report` handler, that's the whole integration:
 *
 *   sendCallToTuner(message);   // fire-and-forget; never throws
 *
 *   // optional: attach your own metadata object to the call in Tuner
 *   sendCallToTuner(message, { customer_id: "cus_123", campaign: "june-promo" });
 *
 * Every finished call, including its tool calls and tool results, will show
 * up under your Tuner agent.
 *
 * The function automatically downloads VAPI's call log (`artifact.logUrl`,
 * one extra GET, no API key needed) to enrich the call with exact per-turn
 * latencies and interruptions. If the log is missing or unreadable, it falls
 * back to payload-only enrichment by itself, the transcript never depends on
 * the log, and a call is never lost because of it.
 *
 * Setup (4 values, all from the Tuner dashboard):
 *   1. TUNER_BASE_URL   -> your Tuner API base URL
 *   2. TUNER_API_KEY    -> Workspace Settings > API Keys           (starts with "tr_api_")
 *   3. TUNER_WORKSPACE  -> Workspace > General Settings
 *   4. TUNER_AGENT_ID   -> create a "Custom API" agent in Tuner, then
 *                          Agent Settings > Agent Connection > Agent ID
 *
 * Requirements: Node 18+ (uses the built-in fetch). No npm dependencies.
 */

// =============================================================================
// CONFIG, fill these in
// =============================================================================
const TUNER_BASE_URL = "https://api.usetuner.ai"; // your Tuner API base URL
const TUNER_API_KEY = "tr_api_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
const TUNER_WORKSPACE = 0; // your workspace id (number)
const TUNER_AGENT_ID = "your-custom-api-agent-id";

// Network timeout & retry behavior for the POST to Tuner.
const REQUEST_TIMEOUT_MS = 15_000; // per-attempt timeout for the POST to Tuner
const MAX_ATTEMPTS = 3; // retry attempts on transient failures (network, 408, 429, 5xx)

type Json = Record<string, any>;

/** VAPI call log: raw JSONL text, or an array of parsed log-line objects. */
type VapiLog = string | Json[];

// =============================================================================
// THE FUNCTION, call this from your end-of-call handler
// =============================================================================
/**
 * Forward one VAPI `end-of-call-report` message to Tuner.
 *
 * `message` is the `body.message` object VAPI posts to your webhook.
 * `metadata` (optional) is a free-form object stored with the call in Tuner
 * (e.g. your own customer id, campaign, A/B variant). Sent only when you
 * provide it; nothing is sent by default.
 *
 * The VAPI call log is fetched internally from `artifact.logUrl`; when that
 * fails, the payload-only enrichment runs instead, fully automatic.
 * Pass `prefetchedLog` when you already downloaded the log (e.g. to save it
 * locally) so it isn't fetched twice.
 * Safe to call inline, it never throws; failures are logged, not thrown.
 */
export async function sendCallToTuner(
  message: Json,
  metadata?: Json | null,
  prefetchedLog?: Json[] | null,
): Promise<void> {
  try {
    const call: Json = message.call ?? {};
    const artifact: Json = message.artifact ?? {};

    const callId = call.id;
    if (!callId) {
      console.warn("Tuner: end-of-call-report has no call id, call will not be sent");
      return;
    }

    // VAPI keeps the timed, structured transcript under artifact.messages.
    const rawMessages: Json[] = artifact.messages ?? message.messages ?? [];
    const callStartMs = toEpochMs(message.startedAt);
    let segments = buildSegments(rawMessages, callStartMs);
    if (segments.length === 0) {
      console.warn(`Tuner: no transcript segments found, call ${callId} will not be sent`);
      return;
    }

    // Fetch VAPI's call log (one GET, no auth). null on any failure -> fallback.
    const log =
      prefetchedLog !== undefined
        ? prefetchedLog
        : await fetchVapiLog(artifact.logUrl ?? message.logUrl);

    // Attach per-turn latency: STT to the customer turn, LLM/TTS/e2e to the agent reply.
    // With a call log: exact interruptions + real metrics on post-tool replies.
    // Without (or if the log doesn't line up): the verified payload-only logic.
    const turnLatencies: Json[] = artifact.performanceMetrics?.turnLatencies ?? [];
    let logEnriched = false;
    if (log) {
      try {
        logEnriched = enrichFromLog(segments, log, turnLatencies);
      } catch (err) {
        logEnriched = false;
        console.warn(
          `Tuner: log enrichment failed for call ${callId} (${String(err)}), using payload-only metrics`,
        );
      }
    }
    if (!logEnriched) {
      if (turnLatencies.length) {
        enrichWithTurnLatencies(segments, turnLatencies);
      }
      // Without log ground truth, deduce barge-ins from timing overlap.
      markInterruptions(segments);
    }
    // Re-join customer utterances that VAPI's stitcher sliced into two rows.
    mergeUserSlices(segments);
    segments = segments.map(stripNull);

    const stereoUrl = message.stereoRecordingUrl ?? artifact.stereoRecordingUrl ?? null;
    const recordingUrl = message.recordingUrl ?? artifact.recordingUrl ?? stereoUrl;
    if (!recordingUrl) {
      console.warn(`Tuner: no recording URL, call ${callId} will not be sent to Tuner`);
      return;
    }

    // Metadata is sent only when the caller provides it; Tuner accepts
    // free-form keys here and stores them with the call. It must be JSON-
    // serializable, anything else is dropped so the call itself still sends.
    let meta: Json | null =
      metadata && typeof metadata === "object" && !Array.isArray(metadata) && Object.keys(metadata).length
        ? metadata
        : null;
    if (meta) {
      try {
        JSON.stringify(meta);
      } catch {
        console.warn(`Tuner: metadata for call ${callId} is not JSON-serializable, sending without it`);
        meta = null;
      }
    }

    const { callAnalysis, callSuccessful } = mapAnalysis(message);
    // sip_call_id: the SIP Call-ID of the leg that reached VAPI, exposed by
    // VAPI as the "cid" variable (and as sbcCallSid). Tuner stores this same
    // id for its simulation calls, so it links the call back to a simulation
    // run. The correlation-id override variable is a last resort; nulls are
    // dropped below, so regular calls are unaffected.
    const correlationId =
      call.assistantOverrides?.variableValues?.["cid"] ??
      call.transport?.sbcCallSid ??
      call.phoneCallProviderDetails?.sbcCallId ??
      call.assistantOverrides?.variableValues?.["correlation-id"] ??
      null;

    // recipient: the call's destination address.
    const recipient = call.customer?.sipUri ?? call.customer?.number ?? null;
    const payload: Json = {
      call_id: callId,
      sip_call_id: correlationId,
      recipient,
      call_type: mapCallType(call.type),
      start_timestamp: callStartMs,
      end_timestamp: toEpochMs(message.endedAt),
      recording_url: recordingUrl,
      recording_multi_channel_url: stereoUrl,
      transcript: message.transcript ?? artifact.transcript ?? null,
      transcript_with_tool_calls: segments,
      call_status: "call_ended",
      disconnection_reason:
        typeof message.endedReason === "string" ? message.endedReason.slice(0, 100) || null : null,
      caller_phone_number: call.customer?.number ?? null,
      call_successful: callSuccessful,
      call_analysis: callAnalysis,
      call_cost: costInCents(message.cost),
      metadata: meta,
    };
    // Drop empty optional fields so we only send what we actually have.
    for (const key of Object.keys(payload)) {
      if (payload[key] === null || payload[key] === undefined) delete payload[key];
    }

    const params = new URLSearchParams({
      workspace_id: String(TUNER_WORKSPACE),
      agent_remote_identifier: TUNER_AGENT_ID,
    });
    const url = `${TUNER_BASE_URL}/api/v1/public/call?${params}`;
    const body = JSON.stringify(payload);

    // POST with retries. Transient failures (network errors, timeouts, 429,
    // 5xx) get up to 3 attempts with exponential backoff + jitter; other 4xx
    // are real errors that retrying can't fix, so they fail fast. Retrying is
    // safe because ingestion is idempotent: a duplicate call_id returns 409,
    // which counts as success.
    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
      let status = 0;
      let detail = "";
      let retryAfterMs = 0;
      try {
        const response = await fetch(url, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${TUNER_API_KEY}`,
            "Content-Type": "application/json",
          },
          body,
          signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
        });
        status = response.status;
        if (status === 200 || status === 201) {
          console.info(`Tuner: sent call ${callId}${logEnriched ? " (log-enriched)" : ""}`);
          return;
        }
        if (status === 409) {
          console.info(`Tuner: call ${callId} already sent`); // idempotent, fine
          return;
        }
        detail = await response.text().catch(() => "");
        const retryable = status === 408 || status === 429 || status >= 500;
        if (!retryable) {
          console.warn(`Tuner: call ${callId} failed (${status}), not retryable: ${detail}`);
          return;
        }
        const ra = Number(response.headers?.get?.("retry-after"));
        if (Number.isFinite(ra) && ra > 0) retryAfterMs = Math.min(ra * 1000, 30_000);
      } catch (err) {
        detail = String(err); // network error / timeout -> retryable
      }
      if (attempt < MAX_ATTEMPTS) {
        const backoffMs = Math.pow(2, attempt) * 1000 + Math.random() * 1000; // ~2-3s, ~4-5s
        const waitMs = Math.max(backoffMs, retryAfterMs);
        console.warn(
          `Tuner: attempt ${attempt}/${MAX_ATTEMPTS} for call ${callId} failed (${status || "network"}), retrying in ${Math.round(waitMs / 1000)}s`,
        );
        await new Promise((resolve) => setTimeout(resolve, waitMs));
      } else {
        console.warn(
          `Tuner: call ${callId} failed after ${MAX_ATTEMPTS} attempts (${status || "network"}) ${detail}`,
        );
      }
    }
  } catch (err) {
    // never let Tuner break your call handling
    console.warn("Tuner: error sending call:", err);
  }
}

// =============================================================================
// VAPI CALL LOG, internal fetch + log-based enrichment
// =============================================================================
/**
 * Download and parse a VAPI call log (`message.artifact.logUrl`).
 *
 * The log is a gzipped JSONL file on VAPI's storage; no API key is needed.
 * Returns the parsed log lines, or null on any failure (missing URL, timeout,
 * bad response), the caller then falls back to payload-only enrichment.
 */
export async function fetchVapiLog(logUrl: unknown): Promise<Json[] | null> {
  if (typeof logUrl !== "string" || !logUrl) return null;
  try {
    const res = await fetch(logUrl, { signal: AbortSignal.timeout(10_000) });
    if (!res.ok) {
      console.warn(
        `Tuner: could not fetch VAPI call log (HTTP ${res.status}), continuing without log enrichment`,
      );
      return null;
    }
    const buf = new Uint8Array(await res.arrayBuffer());

    let text: string;
    if (buf[0] === 0x1f && buf[1] === 0x8b) {
      // The log is gzipped, decompress with Node's built-in zlib.
      const zlib = (await import("node:" + "zlib")) as any;
      text = zlib.gunzipSync(buf).toString("utf8");
    } else {
      text = new TextDecoder().decode(buf);
    }

    const out: Json[] = [];
    for (const line of text.split("\n")) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      try {
        out.push(JSON.parse(trimmed));
      } catch {
        // skip malformed lines
      }
    }
    return out.length ? out : null;
  } catch (err) {
    console.warn(
      `Tuner: could not read VAPI call log (${String(err)}), continuing without log enrichment`,
    );
    return null;
  }
}

type LogEvent = {
  turnId: number;
  time: number;
  event: string;
  latency: number | null;
  wasInterruption: boolean;
};

/** Pull the per-turn events we use out of the raw log lines. */
function normalizeLogEvents(log: VapiLog): LogEvent[] {
  const lines: Json[] =
    typeof log === "string"
      ? log
          .split("\n")
          .map((l) => {
            try {
              return JSON.parse(l);
            } catch {
              return null;
            }
          })
          .filter((l): l is Json => l !== null)
      : log;

  const events: LogEvent[] = [];
  for (const line of lines) {
    const a = line?.attributes;
    if (!a || typeof a !== "object") continue;
    const rawTurn = a.turnId;
    // numeric turnIds only; "CLEAN_UP" etc. are not conversation turns
    if (rawTurn === undefined || rawTurn === null || !/^\d+$/.test(String(rawTurn))) continue;
    if (typeof line.time !== "number" || typeof a.event !== "string") continue;
    events.push({
      turnId: Number(rawTurn),
      time: line.time,
      event: a.event,
      latency: typeof a.latency === "number" ? a.latency : null,
      wasInterruption: a.wasInterruption === true,
    });
  }
  events.sort((x, y) => x.time - y.time);
  return events;
}

type LogFragment = {
  channel: string;
  turnId: number | null;
  text: string;
  time: number; // when the final transcript ARRIVED (ordering)
  spokenTime: number; // when the speech was HEARD (first partial of the chain)
};

/**
 * Pull the speech snippets out of the log: who said it, which turn, and when
 * it was actually spoken (taken from the live partial transcripts, which are
 * more reliable than the final transcript's arrival time).
 */
function extractFragments(log: VapiLog): LogFragment[] {
  const lines: Json[] =
    typeof log === "string"
      ? log
          .split("\n")
          .map((l) => {
            try {
              return JSON.parse(l);
            } catch {
              return null;
            }
          })
          .filter((l): l is Json => l !== null)
      : log;

  const frags: LogFragment[] = [];
  const partialStart: Record<string, number | null> = { user: null, assistant: null };
  for (const line of lines) {
    const a = line?.attributes;
    if (!a || typeof a !== "object") continue;
    const time = typeof line.time === "number" ? line.time : 0;
    const channel = a.channel === "user" ? "user" : "assistant";

    if (a.event === "assistant.transcriber.partialTranscript") {
      if (partialStart[channel] === null) partialStart[channel] = time;
      continue;
    }
    if (a.event !== "assistant.transcriber.finalTranscript") continue;
    if (typeof a.transcript !== "string" || !a.transcript) continue;

    const rawTurn = a.turnId;
    const turnId = rawTurn != null && /^\d+$/.test(String(rawTurn)) ? Number(rawTurn) : null;
    frags.push({
      channel,
      turnId,
      text: a.transcript,
      time,
      spokenTime: partialStart[channel] ?? time,
    });
    partialStart[channel] = null; // next partial starts a new chain
  }
  frags.sort((x, y) => x.time - y.time);
  return frags;
}

/** Lowercase letters and digits only, cosmetic differences can never break matching. */
function normText(s: string): string {
  return (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
}

/**
 * Match every transcript row to the log snippets that make up its text.
 * Each VAPI transcript row is the log's speech snippets glued together, so we
 * re-spell the rows from the snippets, each row then knows exactly which
 * snippets (and turns/timings) it is made of. If the texts don't line up,
 * returns null and the simpler enrichment is used instead.
 */
function alignRowsToFragments(
  segments: Json[],
  frags: LogFragment[],
): { fragsOf: Map<Json, LogFragment[]>; covered: Map<Json, boolean> } | null {
  const fragsOf = new Map<Json, LogFragment[]>();
  const covered = new Map<Json, boolean>();

  for (const [channel, role] of [
    ["user", "user"],
    ["assistant", "agent"],
  ] as const) {
    const rows = segments.filter((s) => s.role === role && typeof s.text === "string");
    const fItems = frags
      .filter((f) => f.channel === channel)
      .map((f) => ({ frag: f, text: normText(f.text) }))
      .filter((f) => f.text);
    const rItems = rows.map((r) => ({ row: r, text: normText(r.text) }));

    const bigR = rItems.map((r) => r.text).join("");
    const bigF = fItems.map((f) => f.text).join("");
    if (!bigR.startsWith(bigF)) return null; // real divergence -> don't trust the log

    // Walk both lists; each row consumes fragment characters and collects fragments.
    let fi = 0;
    let fOff = 0;
    for (const { row, text } of rItems) {
      const own: LogFragment[] = [];
      let need = text.length;
      let got = 0;
      while (need > 0 && fi < fItems.length) {
        const f = fItems[fi];
        const take = Math.min(f.text.length - fOff, need);
        own.push(f.frag);
        need -= take;
        got += take;
        fOff += take;
        if (fOff === f.text.length) {
          fi += 1;
          fOff = 0;
        }
      }
      fragsOf.set(row, own);
      covered.set(row, text.length > 0 && got === text.length);
    }
  }
  return { fragsOf, covered };
}

/**
 * Enrich the transcript using VAPI's call log, the most accurate source.
 * The log tells us exactly which latency numbers belong to which rows, the
 * true metrics for replies that follow a tool call, and exactly which agent
 * sentences the customer interrupted.
 *
 * Returns true when applied; false means the log couldn't be used and the
 * simpler payload-based enrichment runs instead.
 */
function enrichFromLog(segments: Json[], log: VapiLog, turnLatencies: Json[]): boolean {
  const events = normalizeLogEvents(log);
  const frags = extractFragments(log);
  if (!events.length || !frags.length) return false;

  // Group events by turn; "completed" turns produced agent output (speech or
  // a tool invocation), these are the turns turnLatencies entries describe.
  const byTurn = new Map<number, LogEvent[]>();
  for (const e of events) {
    const list = byTurn.get(e.turnId) ?? [];
    list.push(e);
    byTurn.set(e.turnId, list);
  }
  const completedTurnIds = [...byTurn.entries()]
    .sort((a, b) => a[0] - b[0])
    .filter(([, evs]) =>
      evs.some((e) => e.event === "pipeline.botSpeechStarted" || e.event === "assistant.tool.started"),
    )
    .map(([tid]) => tid);
  if (!completedTurnIds.length) return false;

  // Bind rows to their fragments by text.
  const aligned = alignRowsToFragments(segments, frags);
  if (!aligned) return false;
  const { fragsOf, covered } = aligned;

  // Figure out which turn produced each piece of agent speech, using WHEN it
  // was spoken vs when each turn started speaking (the log's own turn labels
  // can lag behind for interrupted speech, so timing is the reliable signal).
  const botStarts: { tid: number; time: number }[] = [];
  for (const tid of completedTurnIds) {
    for (const e of byTurn.get(tid)!) {
      if (e.event === "pipeline.botSpeechStarted") botStarts.push({ tid, time: e.time });
    }
  }
  botStarts.sort((a, b) => a.time - b.time);
  const producingTurn = (t: number): number | null => {
    let res: number | null = null;
    for (const b of botStarts) {
      if (b.time <= t) res = b.tid;
      else break;
    }
    return res;
  };

  const turnsOf = new Map<Json, Set<number>>();
  for (const s of segments) {
    const own = fragsOf.get(s);
    if (!own) continue;
    const turns = new Set<number>();
    if (s.role === "agent") {
      for (const f of own) {
        const t = producingTurn(f.spokenTime);
        if (t !== null) turns.add(t);
      }
    } else {
      // User tags are timely: the tag is the turn that started when they spoke.
      for (const f of own) if (f.turnId !== null) turns.add(f.turnId);
    }
    turnsOf.set(s, turns);
  }

  // The final goodbye is often cut off before its transcript finalizes,
  // pair any leftover turns with the remaining unmatched agent rows.
  const assigned = new Set<number>();
  for (const s of segments) {
    if (s.role !== "agent") continue;
    for (const t of turnsOf.get(s) ?? []) assigned.add(t);
  }
  const leftoverTurns = completedTurnIds.filter((t) => !assigned.has(t));
  const uncoveredAgents = segments.filter(
    (s) =>
      s.role === "agent" &&
      covered.get(s) === false &&
      (turnsOf.get(s)?.size ?? 0) === 0 &&
      normText(s.text).length > 0, // a row with no real words can't claim a turn
  );
  for (let i = 0; i < Math.min(leftoverTurns.length, uncoveredAgents.length); i++) {
    turnsOf.get(uncoveredAgents[i])?.add(leftoverTurns[i]);
  }

  // Build lookups: which agent rows belong to each turn, and when each
  // customer row started speaking.
  const rowsOfTurn = new Map<number, Json[]>();
  const userRows: { row: Json; minTurn: number; spoken: number }[] = [];
  const rowSpoken = new Map<Json, number>();
  for (const s of segments) {
    const own = fragsOf.get(s);
    if (own?.length) rowSpoken.set(s, own[0].spokenTime);
    const turns = turnsOf.get(s);
    if (!turns || !turns.size) continue;
    if (s.role === "agent") {
      for (const t of turns) {
        const list = rowsOfTurn.get(t) ?? [];
        list.push(s);
        rowsOfTurn.set(t, list);
      }
    } else if (s.role === "user") {
      userRows.push({ row: s, minTurn: Math.min(...turns), spoken: rowSpoken.get(s) ?? 0 });
    }
  }

  // When each turn first produced output, the customer speech that triggered
  // a turn must have started before this moment.
  const turnFirstOut = new Map<number, number>();
  for (const tid of completedTurnIds) {
    for (const e of byTurn.get(tid)!) {
      if (e.event === "pipeline.botSpeechStarted" || e.event === "assistant.tool.started") {
        turnFirstOut.set(tid, Math.min(turnFirstOut.get(tid) ?? Infinity, e.time));
      }
    }
  }

  // Attach VAPI's latency numbers: entry #i describes completed turn #i.
  for (let i = 0; i < completedTurnIds.length; i++) {
    const T = completedTurnIds[i];
    const tl: Json | undefined = turnLatencies[i];
    const evs = byTurn.get(T)!;
    const speechRows = rowsOfTurn.get(T) ?? [];

    // STT goes to the customer row that triggered this turn. If one customer
    // row triggered several measured exchanges, keep the largest value, the
    // worst transcription delay that speech experienced.
    if (tl?.transcriberLatency != null) {
      const firstOut = turnFirstOut.get(T) ?? Infinity;
      const trigger = [...userRows]
        .reverse()
        .find((u) => u.minTurn <= T && u.spoken < firstOut)?.row;
      if (trigger) {
        const prev = trigger.metadata?.stt_node_ttfb;
        if (prev === undefined || tl.transcriberLatency > prev) {
          trigger.metadata = { ...(trigger.metadata ?? {}), stt_node_ttfb: tl.transcriberLatency };
        }
      }
    }

    // Latency/LLM/TTS go to the turn's first spoken reply.
    if (speechRows[0] && tl && speechRows[0].metadata?.e2e_latency === undefined) {
      const md: Json = {};
      if (tl.turnLatency != null) md.e2e_latency = tl.turnLatency;
      if (tl.modelLatency != null) md.llm_node_ttft = tl.modelLatency;
      if (tl.voiceLatency != null) md.tts_node_ttfb = tl.voiceLatency;
      if (Object.keys(md).length) speechRows[0].metadata = { ...(speechRows[0].metadata ?? {}), ...md };
    }

    // Replies spoken after a tool finished get their own metrics from the log.
    const runs = evs.filter((e) => e.event === "pipeline.botSpeechStarted");
    for (let k = 1; k < Math.min(speechRows.length, runs.length); k++) {
      if (speechRows[k].metadata?.e2e_latency !== undefined) continue;
      const runStart = runs[k].time;
      const lastBefore = (name: string) =>
        [...evs].reverse().find((e) => e.event === name && e.time < runStart);
      const llm = lastBefore("assistant.model.firstTokenReceived");
      const tts = lastBefore("assistant.voice.firstAudioReceived");
      const tool = lastBefore("assistant.tool.completed");
      const md: Json = {};
      if (tool) md.e2e_latency = runStart - tool.time; // wait after the tool returned
      if (llm?.latency != null) md.llm_node_ttft = llm.latency;
      if (tts?.latency != null) md.tts_node_ttfb = tts.latency;
      if (Object.keys(md).length) speechRows[k].metadata = { ...(speechRows[k].metadata ?? {}), ...md };
    }

  }

  // Interruptions: the log marks every barge-in the assistant reacted to.
  // We find which stretch of agent speech the cut landed inside and flag that
  // row. Barge-ins that happened before the agent even spoke have no row to
  // flag, so they are skipped.
  // Time spans when the agent was actually speaking, and which turn each span belongs to.
  const intervals: { tid: number; start: number; end: number }[] = [];
  let open: { tid: number; start: number } | null = null;
  for (const e of events) {
    if (e.event === "pipeline.botSpeechStarted") {
      if (open) intervals.push({ ...open, end: e.time });
      open = { tid: e.turnId, start: e.time };
    } else if (e.event === "pipeline.botSpeechStopped" && open) {
      intervals.push({ ...open, end: e.time });
      open = null;
    }
  }
  if (open) intervals.push({ ...open, end: Infinity });

  for (const e of events) {
    if (e.event !== "pipeline.cleared" || !e.wasInterruption) continue;
    // Match the LATEST interval containing the cut (with a little slack for
    // event-ordering jitter), when two speech runs are back to back, the cut
    // belongs to the one that was actually playing.
    let hit: { tid: number; start: number; end: number } | null = null;
    for (const iv of intervals) {
      if (iv.start <= e.time && e.time <= iv.end + 100) hit = iv;
    }
    if (!hit) continue; // pre-speech cancellation: nothing was said, nothing to flag
    const rows = rowsOfTurn.get(hit.tid) ?? [];
    if (!rows.length) continue;
    // The cut row: among the turn's rows belonging to this speech run (spoken
    // after the run started, with slack for transcriber lag), the last one
    // already being spoken at the cut.
    const inRun = rows.filter((r) => (rowSpoken.get(r) ?? 0) >= hit.start - 1000);
    const pool = inRun.length ? inRun : rows;
    const spoken = pool.filter((r) => (rowSpoken.get(r) ?? 0) <= e.time + 100);
    const row = spoken[spoken.length - 1] ?? pool[0];
    row.metadata = { ...(row.metadata ?? {}), interrupted: true };
  }

  // VAPI sometimes splits one continuous agent sentence into two rows (when
  // the customer says something brief mid-sentence). The second piece has no
  // measurements of its own, so it would look like an empty row, re-join it
  // with its first half. Only provable same-turn pieces with no metrics of
  // their own are merged; everything else is left untouched.
  for (let i = segments.length - 1; i > 0; i--) {
    const r = segments[i];
    if (r.role !== "agent") continue;
    const md = r.metadata ?? {};
    if (md.e2e_latency !== undefined || md.llm_node_ttft !== undefined || md.tts_node_ttfb !== undefined) {
      continue; // has its own measurement -> a real, separate utterance
    }
    const rTurns = turnsOf.get(r);
    if (!rTurns || !rTurns.size) continue; // unbound -> not provably a slice
    let pi = i - 1;
    while (pi >= 0 && segments[pi].role !== "agent") pi--;
    if (pi < 0) continue;
    const p = segments[pi];
    const pTurns = turnsOf.get(p);
    if (!pTurns || ![...rTurns].every((t) => pTurns.has(t))) continue; // different turn
    if (md.interrupted && p.metadata?.interrupted) continue; // two distinct cuts -> keep both visible

    p.text = `${p.text ?? ""} ${r.text ?? ""}`.trim();
    const rEnd =
      typeof r.end_ms === "number"
        ? r.end_ms
        : typeof r.start_ms === "number" && typeof r.duration_ms === "number"
          ? r.start_ms + r.duration_ms
          : null;
    if (rEnd !== null) {
      p.end_ms = typeof p.end_ms === "number" ? Math.max(p.end_ms, rEnd) : rEnd;
    }
    if (typeof p.start_ms === "number" && typeof p.end_ms === "number") {
      p.duration_ms = p.end_ms - p.start_ms;
    }
    if (Array.isArray(r.words) && r.words.length) p.words = [...(p.words ?? []), ...r.words];
    if (md.interrupted) p.metadata = { ...(p.metadata ?? {}), interrupted: true };
    segments.splice(i, 1);
  }
  return true;
}

/**
 * Re-join customer sentences that VAPI split into two rows mid-sentence.
 * Merged only when clearly one utterance: the rows are back to back with
 * essentially no pause, and at most one of them carries an STT value
 * (two measured rows are two real utterances and stay separate).
 */
function mergeUserSlices(segments: Json[]): void {
  for (let i = segments.length - 1; i > 0; i--) {
    const r = segments[i];
    const p = segments[i - 1];
    if (r.role !== "user" || p.role !== "user") continue;
    const gap =
      typeof r.start_ms === "number" && typeof p.end_ms === "number"
        ? r.start_ms - p.end_ms
        : Infinity;
    if (Math.abs(gap) > 1000) continue; // not a slice boundary
    const rStt = r.metadata?.stt_node_ttfb;
    const pStt = p.metadata?.stt_node_ttfb;
    if (rStt !== undefined && pStt !== undefined) continue; // two measured utterances

    p.text = `${p.text ?? ""} ${r.text ?? ""}`.trim();
    const rEnd =
      typeof r.end_ms === "number"
        ? r.end_ms
        : typeof r.start_ms === "number" && typeof r.duration_ms === "number"
          ? r.start_ms + r.duration_ms
          : null;
    if (rEnd !== null) {
      p.end_ms = typeof p.end_ms === "number" ? Math.max(p.end_ms, rEnd) : rEnd;
    }
    if (typeof p.start_ms === "number" && typeof p.end_ms === "number") {
      p.duration_ms = p.end_ms - p.start_ms;
    }
    if (Array.isArray(r.words) && r.words.length) p.words = [...(p.words ?? []), ...r.words];
    if (rStt !== undefined && pStt === undefined) {
      p.metadata = { ...(p.metadata ?? {}), stt_node_ttfb: rStt };
    }
    if (r.metadata?.interrupted) p.metadata = { ...(p.metadata ?? {}), interrupted: true };
    segments.splice(i, 1);
  }
}

// =============================================================================
// CALL-LEVEL MAPPING, mirrors how Tuner maps VAPI calls natively.
// =============================================================================
// VAPI call types -> Tuner's canonical snake_case values; unknown types pass through.
const VAPI_CALL_TYPE_MAP: Record<string, string> = {
  webCall: "web_call",
  phoneCall: "phone_call",
  sipCall: "sip_call",
};

/** Normalize VAPI's camelCase call type to Tuner's snake_case. */
function mapCallType(vapiType: string | undefined | null): string {
  if (!vapiType) return "phone_call";
  return VAPI_CALL_TYPE_MAP[vapiType] ?? vapiType;
}

/** Build Tuner's call_analysis ({summary, success_evaluation}) and call_successful. */
function mapAnalysis(message: Json): { callAnalysis: Json | null; callSuccessful: boolean | null } {
  const callAnalysis: Json = {};
  let callSuccessful: boolean | null = null;

  const analysis: Json = message.analysis ?? {};
  if (analysis.summary) {
    callAnalysis.summary = analysis.summary;
  }
  const successEval = analysis.successEvaluation;
  if (successEval !== undefined && successEval !== null) {
    callAnalysis.success_evaluation = successEval;
    callSuccessful = String(successEval).toLowerCase() === "true";
  }

  // Fall back to the top-level summary if analysis didn't carry one.
  if (message.summary && !("summary" in callAnalysis)) {
    callAnalysis.summary = message.summary;
  }

  return {
    callAnalysis: Object.keys(callAnalysis).length ? callAnalysis : null,
    callSuccessful,
  };
}

/** VAPI reports cost in dollars; Tuner expects cents. */
function costInCents(cost: unknown): number | null {
  if (typeof cost !== "number" || Number.isNaN(cost)) return null;
  return Math.round(cost * 100 * 100) / 100; // round to 2 decimals
}

/** Drop null/undefined values recursively so segments only carry fields that exist. */
function stripNull<T>(obj: T): T {
  if (Array.isArray(obj)) {
    return obj.map(stripNull) as T;
  }
  if (obj !== null && typeof obj === "object") {
    const out: Json = {};
    for (const [k, v] of Object.entries(obj)) {
      if (v !== null && v !== undefined) out[k] = stripNull(v);
    }
    return out as T;
  }
  return obj;
}

// =============================================================================
// MAPPING, VAPI messages -> Tuner's transcript timeline.
// Mirrors Tuner's internal VAPI mapper so user/agent speech, tool calls, and
// tool results are all captured. VAPI roles map to Tuner roles like so:
//   user  -> user        bot         -> agent
//   tool_calls -> agent_function     tool_call_result -> agent_result
//   system -> dropped
// =============================================================================
function buildSegments(rawMessages: Json[], callStartMs: number | null): Json[] {
  const segments: Json[] = [];
  for (const m of rawMessages) {
    if (typeof m !== "object" || m === null) continue;
    const role = m.role;
    if (role === "user") {
      const seg = speechSegment(m, "user", callStartMs);
      if (seg) segments.push(seg);
    } else if (role === "bot" || role === "assistant") {
      const seg = speechSegment(m, "agent", callStartMs);
      if (seg) segments.push(seg);
    } else if (role === "tool_calls") {
      segments.push(...toolCallSegments(m));
    } else if (role === "tool_call_result") {
      segments.push(toolResultSegment(m));
    }
    // "system" and anything else is intentionally skipped.
  }
  return segments;
}

/**
 * A spoken row (customer or agent) with its timing. Tuner requires timing on
 * every spoken row, so a rare row with no usable timing is skipped rather
 * than rejecting the whole call.
 */
function speechSegment(m: Json, role: string, callStartMs: number | null): Json | null {
  const { startMs, endMs, durationMs } = segmentTiming(m, callStartMs);
  const segWords = words(m.metadata, callStartMs);

  const hasTiming = startMs !== null && (endMs !== null || durationMs !== null);
  if (!hasTiming && !segWords) {
    console.warn(`Tuner: skipping untimed ${role} segment: ${String(m.message ?? "").slice(0, 50)}`);
    return null;
  }

  const seg: Json = { role, text: m.message };
  if (startMs !== null) seg.start_ms = startMs;
  if (endMs !== null) seg.end_ms = endMs;
  if (durationMs !== null) seg.duration_ms = durationMs;
  if (segWords) seg.words = segWords;
  return seg;
}

/** One agent_function segment per tool the agent invoked. */
function toolCallSegments(m: Json): Json[] {
  const seconds = m.secondsFromStart;
  const startMs = seconds !== undefined && seconds !== null ? Math.ceil(seconds * 1000) : null;
  const out: Json[] = [];
  for (const tc of m.toolCalls ?? []) {
    if (tc == null || typeof tc !== "object") continue;
    const fn: Json = tc.function ?? {};
    const params = maybeJson(fn.arguments);
    out.push({
      role: "agent_function",
      start_ms: startMs,
      end_ms: startMs,
      tool: {
        name: fn.name,
        request_id: tc.id,
        params: typeof params === "object" && params !== null && !Array.isArray(params) ? params : null,
        start_ms: startMs,
        end_ms: startMs,
      },
    });
  }
  return out;
}

/** The result returned by a tool. Non-object results are wrapped so nothing is lost. */
function toolResultSegment(m: Json): Json {
  const seconds = m.secondsFromStart;
  const startMs = seconds !== undefined && seconds !== null ? Math.ceil(seconds * 1000) : null;
  const parsed = maybeJson(m.result);

  let result: Json | null;
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
    result = parsed;
  } else if ((Array.isArray(parsed) && parsed.length) || (typeof parsed === "string" && parsed)) {
    result = { value: parsed };
  } else {
    result = null;
  }

  const { isError, errorMsg } = detectToolError(m, parsed);
  const tool: Json = {
    name: m.name,
    request_id: m.toolCallId,
    result,
    is_error: isError,
    start_ms: startMs,
    end_ms: startMs,
  };
  if (errorMsg) tool.error = errorMsg;
  return { role: "agent_result", start_ms: startMs, end_ms: startMs, tool };
}

/**
 * Detect whether a tool result was an error and extract its message. VAPI has
 * no single standard error flag, so the common explicit signals are checked.
 */
function detectToolError(m: Json, parsed: unknown): { isError: boolean; errorMsg: string | null } {
  // 1. Explicit flags on the VAPI message (extra fields are allowed by VAPI).
  const msgError = m.error;
  if (typeof msgError === "string" && msgError) {
    return { isError: true, errorMsg: msgError };
  }
  if (m.error === true || m.isError === true || m.is_error === true) {
    return { isError: true, errorMsg: typeof msgError === "string" ? msgError : null };
  }
  if (m.success === false) {
    return { isError: true, errorMsg: null };
  }

  // 2. Error-shaped result payload, e.g. {"error": "..."} or {"status": "failed"}.
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
    const p = parsed as Json;
    if (p.error) {
      return { isError: true, errorMsg: typeof p.error === "string" ? p.error : null };
    }
    if (p.success === false) {
      return { isError: true, errorMsg: null };
    }
    const status = p.status;
    if (typeof status === "string" && ["error", "failed", "failure"].includes(status.toLowerCase())) {
      return { isError: true, errorMsg: typeof p.message === "string" ? p.message : null };
    }
  }

  return { isError: false, errorMsg: null };
}

// =============================================================================
// TURN GROUPING + LATENCY ATTACHMENT (payload-only path)
// =============================================================================
type Turn = { trigger: Json | null; rows: Json[]; hasSpeech: boolean };

/**
 * Group transcript rows into conversation turns: each customer message starts
 * a new turn, and everything the agent does until the next customer message
 * (speech, tool calls, tool results) belongs to that turn. The opening
 * greeting, spoken before any customer speech, has no trigger.
 */
function groupIntoTurns(segments: Json[]): Turn[] {
  const turns: Turn[] = [];
  let cur: Turn = { trigger: null, rows: [], hasSpeech: false };
  const flush = () => {
    if (cur.rows.length) turns.push(cur);
  };
  for (const seg of segments) {
    if (seg.role === "user") {
      flush();
      cur = { trigger: seg, rows: [], hasSpeech: false };
    } else if (seg.role === "agent") {
      cur.rows.push(seg);
      cur.hasSpeech = true;
    } else if (seg.role === "agent_function" && cur.hasSpeech) {
      flush();
      cur = { trigger: cur.trigger, rows: [seg], hasSpeech: false };
    } else {
      cur.rows.push(seg); // agent_result, or a tool call opening the turn
    }
  }
  flush();
  return turns;
}

/**
 * Attach VAPI's latency numbers without the call log (fallback path).
 * VAPI reports one latency entry per agent response: STT goes to the customer
 * row that triggered it, and latency/LLM/TTS go to the agent's reply. The
 * opening greeting has no entry. Zeros are real values and are kept.
 */
function enrichWithTurnLatencies(segments: Json[], turnLatencies: Json[]): void {
  // Agent speech before any customer speech (the greeting) never gets an entry.
  const eligible = groupIntoTurns(segments).filter((t) => t.trigger !== null);

  const count = Math.min(eligible.length, turnLatencies.length);
  for (let i = 0; i < count; i++) {
    const turn = eligible[i];
    const tl = turnLatencies[i];

    const trigger = turn.trigger!;
    if (tl.transcriberLatency != null) {
      const prev = trigger.metadata?.stt_node_ttfb;
      if (prev === undefined || tl.transcriberLatency > prev) {
        trigger.metadata = { ...(trigger.metadata ?? {}), stt_node_ttfb: tl.transcriberLatency };
      }
    }

    const speech = turn.rows.find((r) => r.role === "agent");
    if (!speech) continue; // e.g. an endCall turn with no spoken reply
    const metadata: Json = {};
    if (tl.turnLatency != null) metadata.e2e_latency = tl.turnLatency;
    if (tl.modelLatency != null) metadata.llm_node_ttft = tl.modelLatency;
    if (tl.voiceLatency != null) metadata.tts_node_ttfb = tl.voiceLatency;
    if (Object.keys(metadata).length) speech.metadata = { ...(speech.metadata ?? {}), ...metadata };
  }
}

// A customer turn must start at least this many ms before the agent's turn ends
// to count as a barge-in (filters out word-boundary noise and short backchannels).
const INTERRUPTION_THRESHOLD_MS = 200;

/**
 * Flag agent rows the customer talked over (fallback path, no call log).
 * If a customer message starts clearly before the previous agent message
 * ended, that agent message was interrupted. The log-based path detects
 * interruptions more completely; this is the best the payload alone allows.
 */
function markInterruptions(segments: Json[]): void {
  let lastAgentSeg: Json | null = null;
  for (const seg of segments) {
    if (seg.role === "agent") {
      lastAgentSeg = seg;
    } else if (seg.role === "user") {
      if (
        lastAgentSeg &&
        lastAgentSeg.end_ms != null &&
        seg.start_ms != null &&
        seg.start_ms < lastAgentSeg.end_ms - INTERRUPTION_THRESHOLD_MS
      ) {
        lastAgentSeg.metadata = { ...(lastAgentSeg.metadata ?? {}), interrupted: true };
      }
      lastAgentSeg = null; // each customer turn compares against a fresh agent turn
    }
    // tool segments: ignore, keep the current agent turn as the candidate
  }
}

// =============================================================================
// TIMING HELPERS
// =============================================================================
/** Work out a row's start/end/duration relative to the start of the call. */
function segmentTiming(
  m: Json,
  callStartMs: number | null,
): { startMs: number | null; endMs: number | null; durationMs: number | null } {
  let startMs: number | null = null;
  let endMs: number | null = null;
  let durationMs: number | null = null;

  const seconds = m.secondsFromStart;
  if (seconds !== undefined && seconds !== null) {
    startMs = Math.ceil(seconds * 1000);
  }
  if (m.duration !== undefined && m.duration !== null) {
    durationMs = Math.ceil(m.duration);
  }

  if (startMs !== null && durationMs !== null) {
    endMs = startMs + durationMs;
  } else if (callStartMs !== null) {
    if (m.time !== undefined && m.time !== null && startMs === null) {
      startMs = Math.ceil(m.time - callStartMs);
    }
    if (m.endTime !== undefined && m.endTime !== null) {
      endMs = Math.ceil(m.endTime - callStartMs);
    }
    if (startMs !== null && endMs !== null) {
      durationMs = endMs - startMs;
    }
  }
  return { startMs, endMs, durationMs };
}

/**
 * Word-level timing from VAPI. VAPI has used different time units for word
 * timestamps over time, so the unit is detected from the value itself.
 */
function words(metadata: unknown, callStartMs: number | null): Json[] | null {
  if (typeof metadata !== "object" || metadata === null) return null;
  const raw = (metadata as Json).wordLevelConfidence;
  if (!Array.isArray(raw) || raw.length === 0) return null;

  const toRelMs = (v: number): number => {
    if (v > 1e11) return Math.ceil(v - (callStartMs ?? 0)); // epoch ms
    if (v < 1e5) return Math.ceil(v * 1000); // seconds from call start
    return Math.ceil(v); // already ms from call start
  };

  const out: Json[] = [];
  for (const w of raw) {
    if (typeof w !== "object" || w === null || !w.word || typeof w.start !== "number" || typeof w.end !== "number") {
      continue;
    }
    out.push({
      word: w.word,
      start_ms: toRelMs(w.start),
      end_ms: toRelMs(w.end),
      confidence: w.confidence ?? null,
    });
  }
  return out.length ? out : null;
}

/** VAPI sends tool arguments/results as JSON strings; parse them, else pass through. */
function maybeJson(value: unknown): unknown {
  if (typeof value !== "string") return value;
  try {
    return JSON.parse(value);
  } catch {
    return value;
  }
}

/** Accept VAPI's ISO string ('2026-06-10T...Z') or a number; return epoch ms. */
function toEpochMs(value: unknown): number | null {
  if (value === undefined || value === null) return null;
  if (typeof value === "number") return Math.trunc(value);
  const ms = Date.parse(String(value));
  return Number.isNaN(ms) ? null : ms;
}
"""
Send your VAPI calls to Tuner
==============================
Drop this file next to your VAPI server and call ``send_call_to_tuner(message)``
from inside your ``end-of-call-report`` handler, that's the whole integration::

    send_call_to_tuner(message)   # fire-and-forget; never raises

    # optional: attach your own metadata object to the call in Tuner
    send_call_to_tuner(message, {"customer_id": "cus_123", "campaign": "june-promo"})

Every finished call, including its tool calls and tool results, will show up
under your Tuner agent.

The function automatically downloads VAPI's call log (``artifact.logUrl``, one
extra GET, no API key needed) to enrich the call with exact per-turn latencies
and interruptions. If the log is missing or unreadable, it falls back to
payload-only enrichment by itself, the transcript never depends on the log, and
a call is never lost because of it.

Because this is synchronous and may sleep between retries, call it from a
background thread/task in latency-sensitive handlers, e.g.::

    import threading
    threading.Thread(target=send_call_to_tuner, args=(message,), daemon=True).start()

Setup (4 values, all from the Tuner dashboard):
  1. TUNER_BASE_URL   -> your Tuner API base URL
  2. TUNER_API_KEY    -> Workspace Settings > API Keys           (starts with "tr_api_")
  3. TUNER_WORKSPACE  -> Workspace > General Settings
  4. TUNER_AGENT_ID   -> create a "Custom API" agent in Tuner, then
                         Agent Settings > Agent Connection > Agent ID

Install:  pip install requests
"""

from __future__ import annotations

import gzip
import logging
import math
import random
import re
import time
from datetime import datetime, timezone
from typing import Any, Optional, Union
from urllib.parse import urlencode

import requests

logger = logging.getLogger("tuner")

# =============================================================================
# CONFIG, fill these in
# =============================================================================
TUNER_BASE_URL = "https://api.usetuner.ai"  # your Tuner API base URL
TUNER_API_KEY = "tr_api_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
TUNER_WORKSPACE = 0  # your workspace id (number)
TUNER_AGENT_ID = "your-custom-api-agent-id"

# Network timeout & retry behavior for the POST to Tuner.
REQUEST_TIMEOUT_S = 15  # per-attempt timeout for the POST to Tuner
MAX_ATTEMPTS = 3  # retry attempts on transient failures (network, 408, 429, 5xx)

Json = dict[str, Any]

# VapiLog: raw JSONL text, or a list of parsed log-line objects.
VapiLog = Union[str, list[Json]]

# Sentinel meaning "prefetched_log was not passed" (None means "no log, skip fetch").
_MISSING = object()


# =============================================================================
# THE FUNCTION, call this from your end-of-call handler
# =============================================================================
def send_call_to_tuner(
    message: Json,
    metadata: Optional[Json] = None,
    prefetched_log: Any = _MISSING,  # sentinel "not provided"; None == "no log"
) -> None:
    """
    Forward one VAPI ``end-of-call-report`` message to Tuner.

    ``message`` is the ``body.message`` object VAPI posts to your webhook.
    ``metadata`` (optional) is a free-form object stored with the call in Tuner
    (e.g. your own customer id, campaign, A/B variant). Sent only when you
    provide it; nothing is sent by default.

    The VAPI call log is fetched internally from ``artifact.logUrl``; when that
    fails, the payload-only enrichment runs instead, fully automatic. Pass
    ``prefetched_log`` when you already downloaded the log (e.g. to save it
    locally) so it isn't fetched twice.

    Safe to call inline, it never raises; failures are logged, not raised.
    """
    try:
        call: Json = message.get("call") or {}
        artifact: Json = message.get("artifact") or {}

        call_id = call.get("id")
        if not call_id:
            logger.warning("Tuner: end-of-call-report has no call id, call will not be sent")
            return

        # VAPI keeps the timed, structured transcript under artifact.messages.
        raw_messages: list = artifact.get("messages") or message.get("messages") or []
        call_start_ms = _to_epoch_ms(message.get("startedAt"))
        segments = _build_segments(raw_messages, call_start_ms)
        if not segments:
            logger.warning(f"Tuner: no transcript segments found, call {call_id} will not be sent")
            return

        # Fetch VAPI's call log (one GET, no auth). None on any failure -> fallback.
        if prefetched_log is not _MISSING:
            log = prefetched_log
        else:
            log = fetch_vapi_log(artifact.get("logUrl") or message.get("logUrl"))

        # Attach per-turn latency: STT to the customer turn, LLM/TTS/e2e to the agent reply.
        # With a call log: exact interruptions + real metrics on post-tool replies.
        # Without (or if the log doesn't line up): the verified payload-only logic.
        turn_latencies: list = ((artifact.get("performanceMetrics") or {}).get("turnLatencies")) or []
        log_enriched = False
        if log:
            try:
                log_enriched = _enrich_from_log(segments, log, turn_latencies)
            except Exception as err:  # noqa: BLE001
                log_enriched = False
                logger.warning(
                    f"Tuner: log enrichment failed for call {call_id} ({err!r}), "
                    "using payload-only metrics"
                )
        if not log_enriched:
            if turn_latencies:
                _enrich_with_turn_latencies(segments, turn_latencies)
            # Without log ground truth, deduce barge-ins from timing overlap.
            _mark_interruptions(segments)
        # Re-join customer utterances that VAPI's stitcher sliced into two rows.
        _merge_user_slices(segments)
        segments = [_strip_none(s) for s in segments]

        stereo_url = message.get("stereoRecordingUrl") or artifact.get("stereoRecordingUrl")
        recording_url = message.get("recordingUrl") or artifact.get("recordingUrl") or stereo_url
        if not recording_url:
            logger.warning(f"Tuner: no recording URL, call {call_id} will not be sent to Tuner")
            return

        # Metadata is sent only when the caller provides it; Tuner accepts
        # free-form keys here and stores them with the call. It must be JSON-
        # serializable, anything else is dropped so the call itself still sends.
        meta: Optional[Json] = (
            metadata if isinstance(metadata, dict) and len(metadata) else None
        )
        if meta is not None:
            try:
                import json

                json.dumps(meta)
            except (TypeError, ValueError):
                logger.warning(
                    f"Tuner: metadata for call {call_id} is not JSON-serializable, sending without it"
                )
                meta = None

        call_analysis, call_successful = _map_analysis(message)
        # sip_call_id: the SIP Call-ID of the leg that reached VAPI, exposed by
        # VAPI as the "cid" variable (and as sbcCallSid). Tuner stores this same
        # id for its simulation calls, so it links the call back to a simulation
        # run. The correlation-id override variable is a last resort; nulls are
        # dropped below, so regular calls are unaffected.
        overrides_vars = (call.get("assistantOverrides") or {}).get("variableValues") or {}
        correlation_id = (
            overrides_vars.get("cid")
            or (call.get("transport") or {}).get("sbcCallSid")
            or (call.get("phoneCallProviderDetails") or {}).get("sbcCallId")
            or overrides_vars.get("correlation-id")
            or None
        )

        # recipient: the call's destination address.
        customer = call.get("customer") or {}
        recipient = customer.get("sipUri") or customer.get("number") or None
        payload: Json = {
            "call_id": call_id,
            "sip_call_id": correlation_id,
            "recipient": recipient,
            "call_type": _map_call_type(call.get("type")),
            "start_timestamp": call_start_ms,
            "end_timestamp": _to_epoch_ms(message.get("endedAt")),
            "recording_url": recording_url,
            "recording_multi_channel_url": stereo_url,
            "transcript": message.get("transcript") or artifact.get("transcript") or None,
            "transcript_with_tool_calls": segments,
            "call_status": "call_ended",
            "disconnection_reason": (
                (message["endedReason"][:100] or None)
                if isinstance(message.get("endedReason"), str)
                else None
            ),
            "caller_phone_number": customer.get("number") or None,
            "call_successful": call_successful,
            "call_analysis": call_analysis,
            "call_cost": _cost_in_cents(message.get("cost")),
            "metadata": meta,
        }
        # Drop empty optional fields so we only send what we actually have.
        payload = {k: v for k, v in payload.items() if v is not None}

        params = urlencode(
            {
                "workspace_id": str(TUNER_WORKSPACE),
                "agent_remote_identifier": TUNER_AGENT_ID,
            }
        )
        url = f"{TUNER_BASE_URL}/api/v1/public/call?{params}"
        headers = {
            "Authorization": f"Bearer {TUNER_API_KEY}",
            "Content-Type": "application/json",
        }

        # POST with retries. Transient failures (network errors, timeouts, 429,
        # 5xx) get up to 3 attempts with exponential backoff + jitter; other 4xx
        # are real errors that retrying can't fix, so they fail fast. Retrying is
        # safe because ingestion is idempotent: a duplicate call_id returns 409,
        # which counts as success.
        for attempt in range(1, MAX_ATTEMPTS + 1):
            status = 0
            detail = ""
            retry_after_ms = 0
            try:
                response = requests.post(
                    url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT_S
                )
                status = response.status_code
                if status in (200, 201):
                    logger.info(
                        f"Tuner: sent call {call_id}" + (" (log-enriched)" if log_enriched else "")
                    )
                    return
                if status == 409:
                    logger.info(f"Tuner: call {call_id} already sent")  # idempotent, fine
                    return
                detail = response.text or ""
                retryable = status == 408 or status == 429 or status >= 500
                if not retryable:
                    logger.warning(
                        f"Tuner: call {call_id} failed ({status}), not retryable: {detail}"
                    )
                    return
                try:
                    ra = float(response.headers.get("retry-after", ""))
                    if math.isfinite(ra) and ra > 0:
                        retry_after_ms = min(ra * 1000, 30_000)
                except (TypeError, ValueError):
                    pass
            except requests.RequestException as err:
                detail = str(err)  # network error / timeout -> retryable

            if attempt < MAX_ATTEMPTS:
                backoff_ms = (2 ** attempt) * 1000 + random.random() * 1000  # ~2-3s, ~4-5s
                wait_ms = max(backoff_ms, retry_after_ms)
                logger.warning(
                    f"Tuner: attempt {attempt}/{MAX_ATTEMPTS} for call {call_id} failed "
                    f"({status or 'network'}), retrying in {round(wait_ms / 1000)}s"
                )
                time.sleep(wait_ms / 1000)
            else:
                logger.warning(
                    f"Tuner: call {call_id} failed after {MAX_ATTEMPTS} attempts "
                    f"({status or 'network'}) {detail}"
                )
    except Exception as err:  # noqa: BLE001
        # never let Tuner break your call handling
        logger.warning(f"Tuner: error sending call: {err!r}")


# =============================================================================
# VAPI CALL LOG, internal fetch + log-based enrichment
# =============================================================================
def fetch_vapi_log(log_url: Any) -> Optional[list[Json]]:
    """
    Download and parse a VAPI call log (``message.artifact.logUrl``).

    The log is a gzipped JSONL file on VAPI's storage; no API key is needed.
    Returns the parsed log lines, or None on any failure (missing URL, timeout,
    bad response), the caller then falls back to payload-only enrichment.
    """
    if not isinstance(log_url, str) or not log_url:
        return None
    try:
        res = requests.get(log_url, timeout=10)
        if not res.ok:
            logger.warning(
                f"Tuner: could not fetch VAPI call log (HTTP {res.status_code}), "
                "continuing without log enrichment"
            )
            return None
        buf = res.content

        if len(buf) >= 2 and buf[0] == 0x1F and buf[1] == 0x8B:
            # The log is gzipped, decompress with the stdlib.
            text = gzip.decompress(buf).decode("utf-8")
        else:
            text = buf.decode("utf-8", errors="replace")

        import json

        out: list[Json] = []
        for line in text.split("\n"):
            trimmed = line.strip()
            if not trimmed:
                continue
            try:
                out.append(json.loads(trimmed))
            except ValueError:
                # skip malformed lines
                pass
        return out or None
    except Exception as err:  # noqa: BLE001
        logger.warning(
            f"Tuner: could not read VAPI call log ({err!r}), continuing without log enrichment"
        )
        return None


def _parse_log_lines(log: VapiLog) -> list[Json]:
    """Accept raw JSONL text or an already-parsed list; return parsed log lines."""
    if not isinstance(log, str):
        return log
    import json

    lines: list[Json] = []
    for line in log.split("\n"):
        try:
            lines.append(json.loads(line))
        except ValueError:
            continue
    return [line for line in lines if line is not None]


def _normalize_log_events(log: VapiLog) -> list[dict]:
    """Pull the per-turn events we use out of the raw log lines."""
    lines = _parse_log_lines(log)

    events: list[dict] = []
    for line in lines:
        a = (line or {}).get("attributes")
        if not isinstance(a, dict):
            continue
        raw_turn = a.get("turnId")
        # numeric turnIds only; "CLEAN_UP" etc. are not conversation turns
        if raw_turn is None or not re.fullmatch(r"\d+", str(raw_turn)):
            continue
        if not isinstance(line.get("time"), (int, float)) or not isinstance(a.get("event"), str):
            continue
        events.append(
            {
                "turnId": int(raw_turn),
                "time": line["time"],
                "event": a["event"],
                "latency": a["latency"] if isinstance(a.get("latency"), (int, float)) else None,
                "wasInterruption": a.get("wasInterruption") is True,
            }
        )
    events.sort(key=lambda x: x["time"])
    return events


def _extract_fragments(log: VapiLog) -> list[dict]:
    """
    Pull the speech snippets out of the log: who said it, which turn, and when
    it was actually spoken (taken from the live partial transcripts, which are
    more reliable than the final transcript's arrival time).
    """
    lines = _parse_log_lines(log)

    frags: list[dict] = []
    partial_start: dict[str, Optional[float]] = {"user": None, "assistant": None}
    for line in lines:
        a = (line or {}).get("attributes")
        if not isinstance(a, dict):
            continue
        time_val = line["time"] if isinstance(line.get("time"), (int, float)) else 0
        channel = "user" if a.get("channel") == "user" else "assistant"

        if a.get("event") == "assistant.transcriber.partialTranscript":
            if partial_start[channel] is None:
                partial_start[channel] = time_val
            continue
        if a.get("event") != "assistant.transcriber.finalTranscript":
            continue
        if not isinstance(a.get("transcript"), str) or not a["transcript"]:
            continue

        raw_turn = a.get("turnId")
        turn_id = int(raw_turn) if raw_turn is not None and re.fullmatch(r"\d+", str(raw_turn)) else None
        frags.append(
            {
                "channel": channel,
                "turnId": turn_id,
                "text": a["transcript"],
                "time": time_val,
                "spokenTime": partial_start[channel] if partial_start[channel] is not None else time_val,
            }
        )
        partial_start[channel] = None  # next partial starts a new chain
    frags.sort(key=lambda x: x["time"])
    return frags


def _norm_text(s: Optional[str]) -> str:
    """Lowercase letters and digits only, cosmetic differences can never break matching."""
    return re.sub(r"[^a-z0-9]", "", (s or "").lower())


def _align_rows_to_fragments(segments: list[Json], frags: list[dict]):
    """
    Match every transcript row to the log snippets that make up its text.
    Each VAPI transcript row is the log's speech snippets glued together, so we
    re-spell the rows from the snippets, each row then knows exactly which
    snippets (and turns/timings) it is made of. If the texts don't line up,
    returns None and the simpler enrichment is used instead.

    Returns ``(frags_of, covered)`` where both are keyed by ``id(row)``.
    """
    frags_of: dict[int, list[dict]] = {}
    covered: dict[int, bool] = {}

    for channel, role in (("user", "user"), ("assistant", "agent")):
        rows = [s for s in segments if s.get("role") == role and isinstance(s.get("text"), str)]
        f_items = [
            {"frag": f, "text": _norm_text(f["text"])}
            for f in frags
            if f["channel"] == channel
        ]
        f_items = [f for f in f_items if f["text"]]
        r_items = [{"row": r, "text": _norm_text(r["text"])} for r in rows]

        big_r = "".join(r["text"] for r in r_items)
        big_f = "".join(f["text"] for f in f_items)
        if not big_r.startswith(big_f):
            return None  # real divergence -> don't trust the log

        # Walk both lists; each row consumes fragment characters and collects fragments.
        fi = 0
        f_off = 0
        for item in r_items:
            row = item["row"]
            text = item["text"]
            own: list[dict] = []
            need = len(text)
            got = 0
            while need > 0 and fi < len(f_items):
                f = f_items[fi]
                take = min(len(f["text"]) - f_off, need)
                own.append(f["frag"])
                need -= take
                got += take
                f_off += take
                if f_off == len(f["text"]):
                    fi += 1
                    f_off = 0
            frags_of[id(row)] = own
            covered[id(row)] = len(text) > 0 and got == len(text)

    return frags_of, covered


def _enrich_from_log(segments: list[Json], log: VapiLog, turn_latencies: list) -> bool:
    """
    Enrich the transcript using VAPI's call log, the most accurate source.
    The log tells us exactly which latency numbers belong to which rows, the
    true metrics for replies that follow a tool call, and exactly which agent
    sentences the customer interrupted.

    Returns True when applied; False means the log couldn't be used and the
    simpler payload-based enrichment runs instead.
    """
    events = _normalize_log_events(log)
    frags = _extract_fragments(log)
    if not events or not frags:
        return False

    # Group events by turn; "completed" turns produced agent output (speech or
    # a tool invocation), these are the turns turnLatencies entries describe.
    by_turn: dict[int, list[dict]] = {}
    for e in events:
        by_turn.setdefault(e["turnId"], []).append(e)
    completed_turn_ids = [
        tid
        for tid, evs in sorted(by_turn.items(), key=lambda kv: kv[0])
        if any(
            e["event"] in ("pipeline.botSpeechStarted", "assistant.tool.started") for e in evs
        )
    ]
    if not completed_turn_ids:
        return False

    # Bind rows to their fragments by text.
    aligned = _align_rows_to_fragments(segments, frags)
    if aligned is None:
        return False
    frags_of, covered = aligned

    # Figure out which turn produced each piece of agent speech, using WHEN it
    # was spoken vs when each turn started speaking (the log's own turn labels
    # can lag behind for interrupted speech, so timing is the reliable signal).
    bot_starts: list[dict] = []
    for tid in completed_turn_ids:
        for e in by_turn[tid]:
            if e["event"] == "pipeline.botSpeechStarted":
                bot_starts.append({"tid": tid, "time": e["time"]})
    bot_starts.sort(key=lambda b: b["time"])

    def producing_turn(t: float) -> Optional[int]:
        res: Optional[int] = None
        for b in bot_starts:
            if b["time"] <= t:
                res = b["tid"]
            else:
                break
        return res

    turns_of: dict[int, set] = {}
    for s in segments:
        own = frags_of.get(id(s))
        if not own:
            continue
        turns: set = set()
        if s.get("role") == "agent":
            for f in own:
                t = producing_turn(f["spokenTime"])
                if t is not None:
                    turns.add(t)
        else:
            # User tags are timely: the tag is the turn that started when they spoke.
            for f in own:
                if f["turnId"] is not None:
                    turns.add(f["turnId"])
        turns_of[id(s)] = turns

    # The final goodbye is often cut off before its transcript finalizes,
    # pair any leftover turns with the remaining unmatched agent rows.
    assigned: set = set()
    for s in segments:
        if s.get("role") != "agent":
            continue
        for t in turns_of.get(id(s), set()):
            assigned.add(t)
    leftover_turns = [t for t in completed_turn_ids if t not in assigned]
    uncovered_agents = [
        s
        for s in segments
        if s.get("role") == "agent"
        and covered.get(id(s)) is False
        and len(turns_of.get(id(s), set())) == 0
        and len(_norm_text(s.get("text"))) > 0  # a row with no real words can't claim a turn
    ]
    for i in range(min(len(leftover_turns), len(uncovered_agents))):
        turns_of.setdefault(id(uncovered_agents[i]), set()).add(leftover_turns[i])

    # Build lookups: which agent rows belong to each turn, and when each
    # customer row started speaking.
    rows_of_turn: dict[int, list[Json]] = {}
    user_rows: list[dict] = []
    row_spoken: dict[int, float] = {}
    for s in segments:
        own = frags_of.get(id(s))
        if own:
            row_spoken[id(s)] = own[0]["spokenTime"]
        turns = turns_of.get(id(s))
        if not turns:
            continue
        if s.get("role") == "agent":
            for t in turns:
                rows_of_turn.setdefault(t, []).append(s)
        elif s.get("role") == "user":
            user_rows.append(
                {"row": s, "minTurn": min(turns), "spoken": row_spoken.get(id(s), 0)}
            )

    # When each turn first produced output, the customer speech that triggered
    # a turn must have started before this moment.
    turn_first_out: dict[int, float] = {}
    for tid in completed_turn_ids:
        for e in by_turn[tid]:
            if e["event"] in ("pipeline.botSpeechStarted", "assistant.tool.started"):
                turn_first_out[tid] = min(turn_first_out.get(tid, math.inf), e["time"])

    # Attach VAPI's latency numbers: entry #i describes completed turn #i.
    for i in range(len(completed_turn_ids)):
        T = completed_turn_ids[i]
        tl: Optional[Json] = turn_latencies[i] if i < len(turn_latencies) else None
        evs = by_turn[T]
        speech_rows = rows_of_turn.get(T, [])

        # STT goes to the customer row that triggered this turn. If one customer
        # row triggered several measured exchanges, keep the largest value, the
        # worst transcription delay that speech experienced.
        if tl is not None and tl.get("transcriberLatency") is not None:
            first_out = turn_first_out.get(T, math.inf)
            trigger = None
            for u in reversed(user_rows):
                if u["minTurn"] <= T and u["spoken"] < first_out:
                    trigger = u["row"]
                    break
            if trigger is not None:
                prev = (trigger.get("metadata") or {}).get("stt_node_ttfb")
                if prev is None or tl["transcriberLatency"] > prev:
                    trigger["metadata"] = {
                        **(trigger.get("metadata") or {}),
                        "stt_node_ttfb": tl["transcriberLatency"],
                    }

        # Latency/LLM/TTS go to the turn's first spoken reply.
        if (
            speech_rows
            and tl is not None
            and "e2e_latency" not in (speech_rows[0].get("metadata") or {})
        ):
            md: Json = {}
            if tl.get("turnLatency") is not None:
                md["e2e_latency"] = tl["turnLatency"]
            if tl.get("modelLatency") is not None:
                md["llm_node_ttft"] = tl["modelLatency"]
            if tl.get("voiceLatency") is not None:
                md["tts_node_ttfb"] = tl["voiceLatency"]
            if md:
                speech_rows[0]["metadata"] = {**(speech_rows[0].get("metadata") or {}), **md}

        # Replies spoken after a tool finished get their own metrics from the log.
        runs = [e for e in evs if e["event"] == "pipeline.botSpeechStarted"]
        for k in range(1, min(len(speech_rows), len(runs))):
            if "e2e_latency" in (speech_rows[k].get("metadata") or {}):
                continue
            run_start = runs[k]["time"]

            def last_before(name: str):
                for e in reversed(evs):
                    if e["event"] == name and e["time"] < run_start:
                        return e
                return None

            llm = last_before("assistant.model.firstTokenReceived")
            tts = last_before("assistant.voice.firstAudioReceived")
            tool = last_before("assistant.tool.completed")
            md = {}
            if tool:
                md["e2e_latency"] = run_start - tool["time"]  # wait after the tool returned
            if llm and llm.get("latency") is not None:
                md["llm_node_ttft"] = llm["latency"]
            if tts and tts.get("latency") is not None:
                md["tts_node_ttfb"] = tts["latency"]
            if md:
                speech_rows[k]["metadata"] = {**(speech_rows[k].get("metadata") or {}), **md}

    # Interruptions: the log marks every barge-in the assistant reacted to.
    # We find which stretch of agent speech the cut landed inside and flag that
    # row. Barge-ins that happened before the agent even spoke have no row to
    # flag, so they are skipped.
    # Time spans when the agent was actually speaking, and which turn each span belongs to.
    intervals: list[dict] = []
    open_iv: Optional[dict] = None
    for e in events:
        if e["event"] == "pipeline.botSpeechStarted":
            if open_iv:
                intervals.append({**open_iv, "end": e["time"]})
            open_iv = {"tid": e["turnId"], "start": e["time"]}
        elif e["event"] == "pipeline.botSpeechStopped" and open_iv:
            intervals.append({**open_iv, "end": e["time"]})
            open_iv = None
    if open_iv:
        intervals.append({**open_iv, "end": math.inf})

    for e in events:
        if e["event"] != "pipeline.cleared" or not e["wasInterruption"]:
            continue
        # Match the LATEST interval containing the cut (with a little slack for
        # event-ordering jitter), when two speech runs are back to back, the cut
        # belongs to the one that was actually playing.
        hit: Optional[dict] = None
        for iv in intervals:
            if iv["start"] <= e["time"] <= iv["end"] + 100:
                hit = iv
        if not hit:
            continue  # pre-speech cancellation: nothing was said, nothing to flag
        rows = rows_of_turn.get(hit["tid"], [])
        if not rows:
            continue
        # The cut row: among the turn's rows belonging to this speech run (spoken
        # after the run started, with slack for transcriber lag), the last one
        # already being spoken at the cut.
        in_run = [r for r in rows if row_spoken.get(id(r), 0) >= hit["start"] - 1000]
        pool = in_run if in_run else rows
        spoken = [r for r in pool if row_spoken.get(id(r), 0) <= e["time"] + 100]
        row = spoken[-1] if spoken else pool[0]
        row["metadata"] = {**(row.get("metadata") or {}), "interrupted": True}

    # VAPI sometimes splits one continuous agent sentence into two rows (when
    # the customer says something brief mid-sentence). The second piece has no
    # measurements of its own, so it would look like an empty row, re-join it
    # with its first half. Only provable same-turn pieces with no metrics of
    # their own are merged; everything else is left untouched.
    for i in range(len(segments) - 1, 0, -1):
        r = segments[i]
        if r.get("role") != "agent":
            continue
        md = r.get("metadata") or {}
        if (
            md.get("e2e_latency") is not None
            or md.get("llm_node_ttft") is not None
            or md.get("tts_node_ttfb") is not None
        ):
            continue  # has its own measurement -> a real, separate utterance
        r_turns = turns_of.get(id(r))
        if not r_turns:
            continue  # unbound -> not provably a slice
        pi = i - 1
        while pi >= 0 and segments[pi].get("role") != "agent":
            pi -= 1
        if pi < 0:
            continue
        p = segments[pi]
        p_turns = turns_of.get(id(p))
        if not p_turns or not all(t in p_turns for t in r_turns):
            continue  # different turn
        if md.get("interrupted") and (p.get("metadata") or {}).get("interrupted"):
            continue  # two distinct cuts -> keep both visible

        p["text"] = f"{p.get('text') or ''} {r.get('text') or ''}".strip()
        r_end = (
            r["end_ms"]
            if isinstance(r.get("end_ms"), (int, float))
            else (
                r["start_ms"] + r["duration_ms"]
                if isinstance(r.get("start_ms"), (int, float))
                and isinstance(r.get("duration_ms"), (int, float))
                else None
            )
        )
        if r_end is not None:
            p["end_ms"] = max(p["end_ms"], r_end) if isinstance(p.get("end_ms"), (int, float)) else r_end
        if isinstance(p.get("start_ms"), (int, float)) and isinstance(p.get("end_ms"), (int, float)):
            p["duration_ms"] = p["end_ms"] - p["start_ms"]
        if isinstance(r.get("words"), list) and r["words"]:
            p["words"] = [*(p.get("words") or []), *r["words"]]
        if md.get("interrupted"):
            p["metadata"] = {**(p.get("metadata") or {}), "interrupted": True}
        del segments[i]
    return True


def _merge_user_slices(segments: list[Json]) -> None:
    """
    Re-join customer sentences that VAPI split into two rows mid-sentence.
    Merged only when clearly one utterance: the rows are back to back with
    essentially no pause, and at most one of them carries an STT value
    (two measured rows are two real utterances and stay separate).
    """
    for i in range(len(segments) - 1, 0, -1):
        r = segments[i]
        p = segments[i - 1]
        if r.get("role") != "user" or p.get("role") != "user":
            continue
        gap = (
            r["start_ms"] - p["end_ms"]
            if isinstance(r.get("start_ms"), (int, float)) and isinstance(p.get("end_ms"), (int, float))
            else math.inf
        )
        if abs(gap) > 1000:
            continue  # not a slice boundary
        r_stt = (r.get("metadata") or {}).get("stt_node_ttfb")
        p_stt = (p.get("metadata") or {}).get("stt_node_ttfb")
        if r_stt is not None and p_stt is not None:
            continue  # two measured utterances

        p["text"] = f"{p.get('text') or ''} {r.get('text') or ''}".strip()
        r_end = (
            r["end_ms"]
            if isinstance(r.get("end_ms"), (int, float))
            else (
                r["start_ms"] + r["duration_ms"]
                if isinstance(r.get("start_ms"), (int, float))
                and isinstance(r.get("duration_ms"), (int, float))
                else None
            )
        )
        if r_end is not None:
            p["end_ms"] = max(p["end_ms"], r_end) if isinstance(p.get("end_ms"), (int, float)) else r_end
        if isinstance(p.get("start_ms"), (int, float)) and isinstance(p.get("end_ms"), (int, float)):
            p["duration_ms"] = p["end_ms"] - p["start_ms"]
        if isinstance(r.get("words"), list) and r["words"]:
            p["words"] = [*(p.get("words") or []), *r["words"]]
        if r_stt is not None and p_stt is None:
            p["metadata"] = {**(p.get("metadata") or {}), "stt_node_ttfb": r_stt}
        if (r.get("metadata") or {}).get("interrupted"):
            p["metadata"] = {**(p.get("metadata") or {}), "interrupted": True}
        del segments[i]


# =============================================================================
# CALL-LEVEL MAPPING, mirrors how Tuner maps VAPI calls natively.
# =============================================================================
# VAPI call types -> Tuner's canonical snake_case values; unknown types pass through.
_VAPI_CALL_TYPE_MAP = {
    "webCall": "web_call",
    "phoneCall": "phone_call",
    "sipCall": "sip_call",
}


def _map_call_type(vapi_type: Any) -> str:
    """Normalize VAPI's camelCase call type to Tuner's snake_case."""
    if not vapi_type:
        return "phone_call"
    return _VAPI_CALL_TYPE_MAP.get(vapi_type, vapi_type)


def _map_analysis(message: Json) -> tuple[Optional[Json], Optional[bool]]:
    """Build Tuner's call_analysis ({summary, success_evaluation}) and call_successful."""
    call_analysis: Json = {}
    call_successful: Optional[bool] = None

    analysis: Json = message.get("analysis") or {}
    if analysis.get("summary"):
        call_analysis["summary"] = analysis["summary"]
    success_eval = analysis.get("successEvaluation")
    if success_eval is not None:
        call_analysis["success_evaluation"] = success_eval
        call_successful = str(success_eval).lower() == "true"

    # Fall back to the top-level summary if analysis didn't carry one.
    if message.get("summary") and "summary" not in call_analysis:
        call_analysis["summary"] = message["summary"]

    return (call_analysis if call_analysis else None), call_successful


def _cost_in_cents(cost: Any) -> Optional[float]:
    """VAPI reports cost in dollars; Tuner expects cents."""
    if not isinstance(cost, (int, float)) or isinstance(cost, bool) or math.isnan(cost):
        return None
    # round half up to 2 decimals, matching JS Math.round semantics
    return math.floor(cost * 100 * 100 + 0.5) / 100


def _strip_none(obj: Any) -> Any:
    """Drop None values recursively so segments only carry fields that exist."""
    if isinstance(obj, list):
        return [_strip_none(v) for v in obj]
    if isinstance(obj, dict):
        return {k: _strip_none(v) for k, v in obj.items() if v is not None}
    return obj


# =============================================================================
# MAPPING, VAPI messages -> Tuner's transcript timeline.
# Mirrors Tuner's internal VAPI mapper so user/agent speech, tool calls, and
# tool results are all captured. VAPI roles map to Tuner roles like so:
#   user  -> user        bot         -> agent
#   tool_calls -> agent_function     tool_call_result -> agent_result
#   system -> dropped
# =============================================================================
def _build_segments(raw_messages: list, call_start_ms: Optional[int]) -> list[Json]:
    segments: list[Json] = []
    for m in raw_messages:
        if not isinstance(m, dict):
            continue
        role = m.get("role")
        if role == "user":
            seg = _speech_segment(m, "user", call_start_ms)
            if seg:
                segments.append(seg)
        elif role in ("bot", "assistant"):
            seg = _speech_segment(m, "agent", call_start_ms)
            if seg:
                segments.append(seg)
        elif role == "tool_calls":
            segments.extend(_tool_call_segments(m))
        elif role == "tool_call_result":
            segments.append(_tool_result_segment(m))
        # "system" and anything else is intentionally skipped.
    return segments


def _speech_segment(m: Json, role: str, call_start_ms: Optional[int]) -> Optional[Json]:
    """
    A spoken row (customer or agent) with its timing. Tuner requires timing on
    every spoken row, so a rare row with no usable timing is skipped rather
    than rejecting the whole call.
    """
    start_ms, end_ms, duration_ms = _segment_timing(m, call_start_ms)
    seg_words = _words(m.get("metadata"), call_start_ms)

    has_timing = start_ms is not None and (end_ms is not None or duration_ms is not None)
    if not has_timing and not seg_words:
        logger.warning(
            f"Tuner: skipping untimed {role} segment: {str(m.get('message') or '')[:50]}"
        )
        return None

    seg: Json = {"role": role, "text": m.get("message")}
    if start_ms is not None:
        seg["start_ms"] = start_ms
    if end_ms is not None:
        seg["end_ms"] = end_ms
    if duration_ms is not None:
        seg["duration_ms"] = duration_ms
    if seg_words:
        seg["words"] = seg_words
    return seg


def _tool_call_segments(m: Json) -> list[Json]:
    """One agent_function segment per tool the agent invoked."""
    seconds = m.get("secondsFromStart")
    start_ms = math.ceil(seconds * 1000) if seconds is not None else None
    out: list[Json] = []
    for tc in m.get("toolCalls") or []:
        if not isinstance(tc, dict):
            continue
        fn: Json = tc.get("function") or {}
        params = _maybe_json(fn.get("arguments"))
        out.append(
            {
                "role": "agent_function",
                "start_ms": start_ms,
                "end_ms": start_ms,
                "tool": {
                    "name": fn.get("name"),
                    "request_id": tc.get("id"),
                    "params": params if isinstance(params, dict) else None,
                    "start_ms": start_ms,
                    "end_ms": start_ms,
                },
            }
        )
    return out


def _tool_result_segment(m: Json) -> Json:
    """The result returned by a tool. Non-object results are wrapped so nothing is lost."""
    seconds = m.get("secondsFromStart")
    start_ms = math.ceil(seconds * 1000) if seconds is not None else None
    parsed = _maybe_json(m.get("result"))

    if isinstance(parsed, dict):
        result: Optional[Json] = parsed
    elif (isinstance(parsed, list) and parsed) or (isinstance(parsed, str) and parsed):
        result = {"value": parsed}
    else:
        result = None

    is_error, error_msg = _detect_tool_error(m, parsed)
    tool: Json = {
        "name": m.get("name"),
        "request_id": m.get("toolCallId"),
        "result": result,
        "is_error": is_error,
        "start_ms": start_ms,
        "end_ms": start_ms,
    }
    if error_msg:
        tool["error"] = error_msg
    return {"role": "agent_result", "start_ms": start_ms, "end_ms": start_ms, "tool": tool}


def _detect_tool_error(m: Json, parsed: Any) -> tuple[bool, Optional[str]]:
    """
    Detect whether a tool result was an error and extract its message. VAPI has
    no single standard error flag, so the common explicit signals are checked.
    """
    # 1. Explicit flags on the VAPI message (extra fields are allowed by VAPI).
    msg_error = m.get("error")
    if isinstance(msg_error, str) and msg_error:
        return True, msg_error
    if m.get("error") is True or m.get("isError") is True or m.get("is_error") is True:
        return True, (msg_error if isinstance(msg_error, str) else None)
    if m.get("success") is False:
        return True, None

    # 2. Error-shaped result payload, e.g. {"error": "..."} or {"status": "failed"}.
    if isinstance(parsed, dict):
        p = parsed
        if p.get("error"):
            return True, (p["error"] if isinstance(p["error"], str) else None)
        if p.get("success") is False:
            return True, None
        status = p.get("status")
        if isinstance(status, str) and status.lower() in ("error", "failed", "failure"):
            return True, (p["message"] if isinstance(p.get("message"), str) else None)

    return False, None


# =============================================================================
# TURN GROUPING + LATENCY ATTACHMENT (payload-only path)
# =============================================================================
def _group_into_turns(segments: list[Json]) -> list[dict]:
    """
    Group transcript rows into conversation turns: each customer message starts
    a new turn, and everything the agent does until the next customer message
    (speech, tool calls, tool results) belongs to that turn. The opening
    greeting, spoken before any customer speech, has no trigger.
    """
    turns: list[dict] = []
    cur: dict = {"trigger": None, "rows": [], "hasSpeech": False}

    def flush():
        if cur["rows"]:
            turns.append(cur)

    for seg in segments:
        role = seg.get("role")
        if role == "user":
            flush()
            cur = {"trigger": seg, "rows": [], "hasSpeech": False}
        elif role == "agent":
            cur["rows"].append(seg)
            cur["hasSpeech"] = True
        elif role == "agent_function" and cur["hasSpeech"]:
            flush()
            cur = {"trigger": cur["trigger"], "rows": [seg], "hasSpeech": False}
        else:
            cur["rows"].append(seg)  # agent_result, or a tool call opening the turn
    flush()
    return turns


def _enrich_with_turn_latencies(segments: list[Json], turn_latencies: list) -> None:
    """
    Attach VAPI's latency numbers without the call log (fallback path).
    VAPI reports one latency entry per agent response: STT goes to the customer
    row that triggered it, and latency/LLM/TTS go to the agent's reply. The
    opening greeting has no entry. Zeros are real values and are kept.
    """
    # Agent speech before any customer speech (the greeting) never gets an entry.
    eligible = [t for t in _group_into_turns(segments) if t["trigger"] is not None]

    count = min(len(eligible), len(turn_latencies))
    for i in range(count):
        turn = eligible[i]
        tl = turn_latencies[i]

        trigger = turn["trigger"]
        if tl.get("transcriberLatency") is not None:
            prev = (trigger.get("metadata") or {}).get("stt_node_ttfb")
            if prev is None or tl["transcriberLatency"] > prev:
                trigger["metadata"] = {
                    **(trigger.get("metadata") or {}),
                    "stt_node_ttfb": tl["transcriberLatency"],
                }

        speech = next((r for r in turn["rows"] if r.get("role") == "agent"), None)
        if not speech:
            continue  # e.g. an endCall turn with no spoken reply
        metadata: Json = {}
        if tl.get("turnLatency") is not None:
            metadata["e2e_latency"] = tl["turnLatency"]
        if tl.get("modelLatency") is not None:
            metadata["llm_node_ttft"] = tl["modelLatency"]
        if tl.get("voiceLatency") is not None:
            metadata["tts_node_ttfb"] = tl["voiceLatency"]
        if metadata:
            speech["metadata"] = {**(speech.get("metadata") or {}), **metadata}


# A customer turn must start at least this many ms before the agent's turn ends
# to count as a barge-in (filters out word-boundary noise and short backchannels).
_INTERRUPTION_THRESHOLD_MS = 200


def _mark_interruptions(segments: list[Json]) -> None:
    """
    Flag agent rows the customer talked over (fallback path, no call log).
    If a customer message starts clearly before the previous agent message
    ended, that agent message was interrupted. The log-based path detects
    interruptions more completely; this is the best the payload alone allows.
    """
    last_agent_seg: Optional[Json] = None
    for seg in segments:
        role = seg.get("role")
        if role == "agent":
            last_agent_seg = seg
        elif role == "user":
            if (
                last_agent_seg
                and last_agent_seg.get("end_ms") is not None
                and seg.get("start_ms") is not None
                and seg["start_ms"] < last_agent_seg["end_ms"] - _INTERRUPTION_THRESHOLD_MS
            ):
                last_agent_seg["metadata"] = {
                    **(last_agent_seg.get("metadata") or {}),
                    "interrupted": True,
                }
            last_agent_seg = None  # each customer turn compares against a fresh agent turn
        # tool segments: ignore, keep the current agent turn as the candidate


# =============================================================================
# TIMING HELPERS
# =============================================================================
def _segment_timing(m: Json, call_start_ms: Optional[int]):
    """Work out a row's start/end/duration relative to the start of the call."""
    start_ms: Optional[int] = None
    end_ms: Optional[int] = None
    duration_ms: Optional[int] = None

    seconds = m.get("secondsFromStart")
    if seconds is not None:
        start_ms = math.ceil(seconds * 1000)
    if m.get("duration") is not None:
        duration_ms = math.ceil(m["duration"])

    if start_ms is not None and duration_ms is not None:
        end_ms = start_ms + duration_ms
    elif call_start_ms is not None:
        if m.get("time") is not None and start_ms is None:
            start_ms = math.ceil(m["time"] - call_start_ms)
        if m.get("endTime") is not None:
            end_ms = math.ceil(m["endTime"] - call_start_ms)
        if start_ms is not None and end_ms is not None:
            duration_ms = end_ms - start_ms
    return start_ms, end_ms, duration_ms


def _words(metadata: Any, call_start_ms: Optional[int]) -> Optional[list[Json]]:
    """
    Word-level timing from VAPI. VAPI has used different time units for word
    timestamps over time, so the unit is detected from the value itself.
    """
    if not isinstance(metadata, dict):
        return None
    raw = metadata.get("wordLevelConfidence")
    if not isinstance(raw, list) or len(raw) == 0:
        return None

    def to_rel_ms(v: float) -> int:
        if v > 1e11:
            return math.ceil(v - (call_start_ms or 0))  # epoch ms
        if v < 1e5:
            return math.ceil(v * 1000)  # seconds from call start
        return math.ceil(v)  # already ms from call start

    out: list[Json] = []
    for w in raw:
        if (
            not isinstance(w, dict)
            or not w.get("word")
            or not isinstance(w.get("start"), (int, float))
            or not isinstance(w.get("end"), (int, float))
        ):
            continue
        out.append(
            {
                "word": w["word"],
                "start_ms": to_rel_ms(w["start"]),
                "end_ms": to_rel_ms(w["end"]),
                "confidence": w.get("confidence"),
            }
        )
    return out or None


def _maybe_json(value: Any) -> Any:
    """VAPI sends tool arguments/results as JSON strings; parse them, else pass through."""
    if not isinstance(value, str):
        return value
    import json

    try:
        return json.loads(value)
    except ValueError:
        return value


def _to_epoch_ms(value: Any) -> Optional[int]:
    """Accept VAPI's ISO string ('2026-06-10T...Z') or a number; return epoch ms."""
    if value is None:
        return None
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        return math.trunc(value)
    try:
        s = str(value).replace("Z", "+00:00")
        dt = datetime.fromisoformat(s)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return int(dt.timestamp() * 1000)
    except (ValueError, TypeError):
        return None
4

Get your keys and configure the file

Open the file and fill in the four config values at the top, all from the Tuner dashboard:
// =============================================================================
// CONFIG, fill these in
// =============================================================================
const TUNER_BASE_URL = "https://api.usetuner.ai"; // your Tuner API base URL
const TUNER_API_KEY = "tr_api_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
const TUNER_WORKSPACE = 0;                          // your workspace id (number)
const TUNER_AGENT_ID = "your-custom-api-agent-id";
# =============================================================================
# CONFIG, fill these in
# =============================================================================
TUNER_BASE_URL = "https://api.usetuner.ai"  # your Tuner API base URL
TUNER_API_KEY = "tr_api_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
TUNER_WORKSPACE = 0  # your workspace id (number)
TUNER_AGENT_ID = "your-custom-api-agent-id"
TUNER_BASE_URL
string
default:"https://api.usetuner.ai"
Your Tuner API base URL.
TUNER_API_KEY
string
required
Found in Workspace Settings → API Keys (starts with tr_api_).
TUNER_WORKSPACE
number
required
Found in Workspace → General Settings (a number).
TUNER_AGENT_ID
string
required
On the Custom API agent: Agent Settings → Agent Connection → Agent ID.
Set the retry logic (just below the config) to taste, these control how the POST to Tuner behaves on transient failures:
// Network timeout & retry behavior for the POST to Tuner.
const REQUEST_TIMEOUT_MS = 15_000; // per-attempt timeout for the POST to Tuner
const MAX_ATTEMPTS = 3;            // retry attempts on transient failures (network, 408, 429, 5xx)
# Network timeout & retry behavior for the POST to Tuner.
REQUEST_TIMEOUT_S = 15  # per-attempt timeout for the POST to Tuner
MAX_ATTEMPTS = 3        # retry attempts on transient failures (network, 408, 429, 5xx)
Retries use exponential backoff with jitter and respect Retry-After; only transient failures are retried (other 4xx fail fast). The defaults are safe, raise MAX_ATTEMPTS if Tuner ingestion is occasionally slow on your network.
5

Call the function where your call ends

Call the forward function (sendCallToTuner in Node, send_call_to_tuner in Python) from inside your VAPI end-of-call-report webhook handler. The single required argument is the message object VAPI posts to your webhook (body.message).
import { sendCallToTuner } from "./send_to_tuner";

// your existing VAPI webhook endpoint
app.post("/vapi/webhook", (req, res) => {
  const message = req.body?.message;

  if (message?.type === "end-of-call-report") {
    // fire-and-forget, never blocks, never throws
    sendCallToTuner(message);
  }

  res.sendStatus(200);
});
from send_to_tuner import send_call_to_tuner

# your existing VAPI webhook endpoint (FastAPI shown)
@app.post("/vapi/webhook")
async def vapi_webhook(request: Request):
    message = (await request.json()).get("message") or {}

    if message.get("type") == "end-of-call-report":
        # never blocks your handler, never raises
        send_call_to_tuner(message)

    return {}
Optional: attach your own metadata. Pass a second free-form object for anything that changes per call or per use case: customer names, the use case, A/B variant, prompt version, even your whole custom system prompt. It’s stored with the call in Tuner, and you can evaluate calls based on it.
sendCallToTuner(message, {
  customer_name: "Yuki Tanaka",
  use_case: "balance_inquiry",
  prompt_version: "v3",
  system_prompt: "You are a helpful assistant for Acme Corp...",
});
send_call_to_tuner(message, {
    "customer_name": "Yuki Tanaka",
    "use_case": "balance_inquiry",
    "prompt_version": "v3",
    "system_prompt": "You are a helpful assistant for Acme Corp...",
})
Metadata must be JSON-serializable; if it isn’t, the call is still sent without it. Nothing is sent by default, omit the argument to send no metadata.To see how Tuner uses this metadata to evaluate each agent against its own instructions, read Evaluating Agents with Dynamic Instructions.
6

That's it

Make a call. When it ends, it appears under your Custom API agent in Tuner with the full transcript, tool calls/results, per-turn latency and interruption metrics, the recording, and cost, plus any metadata you attached.

Setting up simulation

Ingestion gets your real calls into Tuner, simulation lets Tuner place test calls to your VAPI agent and evaluate how it behaves. The integration already sends the sip_call_id Tuner needs to link a simulated call back to its simulation run, so once ingestion is working, simulation only needs a SIP trunk on the VAPI side.

Set up simulation for your VAPI agent

Create a free VAPI SIP trunk, link your assistant, and paste the SIP URI and credentials into Tuner, full walkthrough in the simulation setup guide.

Troubleshooting

Everything is logged with a Tuner: prefix, via console.warn / console.info in Node and the tuner logger (logging) in Python:
Success. (log-enriched) means precise per-turn metrics were attached.
This call was already ingested, safe to ignore.
The call log wasn’t usable; the call is still sent with payload-only metrics.
Tuner rejected the request with an error that retrying can’t fix (any 4xx except 408/429, e.g. a bad API key or wrong agent id), so it fails immediately without using any retry attempts. Check your config values.
A transient failure (network error, timeout, 408, 429, or 5xx) kept happening until all MAX_ATTEMPTS retries were used up. The call was not ingested.
If calls never arrive: double-check TUNER_API_KEY, TUNER_WORKSPACE, and TUNER_AGENT_ID, and confirm your handler only forwards end-of-call-report events.