view mrjunejune/conversation_api.c @ 268:f7b1188d5fb1

support repo rules Copilot bundle path Match both +_repo_rules2+ and +http_archive+ Copilot CLI repository layouts during deployment and production startup. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 13:02:41 -0700
parents 056790c4fb0d
children
line wrap: on
line source

#include "mrjunejune/conversation_api.h"

#include "mrjunejune/auth_api.h"
#include "mrjunejune/inference_bridge.h"
#include "mrjunejune/conversation_store.h"
#include "auth/auth_store.h"
#include "seobeo/seobeo.h"

#include <pthread.h>
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>

#define CONVERSATION_TITLE_MAX 200
#define CONVERSATION_PROMPT_MAX (32 * 1024)
#define CONVERSATION_RESPONSE_MAX (256 * 1024)
#define CONVERSATION_HISTORY_MAX (512 * 1024)
#define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024)
#define CONVERSATION_EVENT_NAME_MAX 64
#define CONVERSATION_ACTIVE_MAX 4
#define CONVERSATION_TURNS_PER_MINUTE 60
#define CONVERSATION_PROMPT_VERSION 1
#define CONVERSATION_KNOWLEDGE_VERSION 1
/* Buffer large enough for a guest Set-Cookie directive */
#define CONV_GUEST_COOKIE_CAPACITY (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)

/* Quota policy — read from env at init, validated at startup. */
#define CONV_GUEST_DAILY_TURNS_DEFAULT          10
#define CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT  20000
#define CONV_GUEST_DAILY_TURNS_MIN              1
#define CONV_GUEST_DAILY_TURNS_MAX              10000
#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN      1
#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX      1000000
#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN    1
/* Default per-request reservation (tokens); capped to daily at init. */
#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048

static Conversation_Store *g_conversation_store = NULL;
static Inference_Bridge *g_inference_bridge = NULL;
static boolean g_guest_inference_enabled = FALSE;
static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER;
static time_t g_admission_window = 0;
static uint32 g_admission_turns = 0;
static uint32 g_active_turns = 0;

/* Quota policy (validated at init). */
static int64 g_guest_daily_turns          = CONV_GUEST_DAILY_TURNS_DEFAULT;
static int64 g_guest_daily_output_tokens  = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
static int64 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;

typedef struct Pending_Turn {
  char request_id[37];
  char conversation_id[37];
  /* owner identity copied before request arena expires (req 8) */
  Conversation_Owner owner;
  Seobeo_SSE_Stream *p_stream;
  char *content;
  size_t content_length;
  size_t content_capacity;
  int64 input_tokens;
  int64 output_tokens;
  boolean failed;
  boolean aborted;
  boolean has_usage_event;   /* TRUE once assistant.usage received           */
  boolean finalized;         /* TRUE once Finalize_Pending has run (once-only) */
  char error_message[512];
  /* Guest quota fields — copied before arena expires. */
  boolean is_guest_reserved;   /* TRUE if quota was reserved for this turn */
  int64   reserved_output_tokens;
  char    guest_id[37];        /* guest_id from principal (safe copy)      */
  struct Pending_Turn *p_next;
} Pending_Turn;

static Pending_Turn *g_pending_turns = NULL;

static boolean Conversation_API_Is_Handled_Event(const char *type)
{
  return
      strcmp(type, "ready") == 0 ||
      strcmp(type, "turn.accepted") == 0 ||
      strcmp(type, "assistant.delta") == 0 ||
      strcmp(type, "assistant.completed") == 0 ||
      strcmp(type, "assistant.usage") == 0 ||
      strcmp(type, "turn.error") == 0 ||
      strcmp(type, "turn.done") == 0;
}

static boolean Conversation_API_Is_Safe_Event_Name(const char *type)
{
  if (!type || type[0] == '\0')
    return FALSE;
  size_t length = strlen(type);
  if (length > CONVERSATION_EVENT_NAME_MAX)
    return FALSE;
  for (size_t i = 0; i < length; i++)
  {
    char character = type[i];
    if (!(
            (character >= 'a' && character <= 'z') ||
            (character >= 'A' && character <= 'Z') ||
            (character >= '0' && character <= '9') ||
            character == '.' ||
            character == '_' ||
            character == '-'))
      return FALSE;
  }
  return TRUE;
}

static boolean Conversation_API_Acquire_Turn_Slot(void)
{
  time_t now = time(NULL);
  pthread_mutex_lock(&g_admission_mutex);
  if (g_admission_window == 0 || now - g_admission_window >= 60)
  {
    g_admission_window = now;
    g_admission_turns = 0;
  }
  boolean allowed =
      g_admission_turns < CONVERSATION_TURNS_PER_MINUTE &&
      g_active_turns < CONVERSATION_ACTIVE_MAX;
  if (allowed)
  {
    g_admission_turns++;
    g_active_turns++;
  }
  pthread_mutex_unlock(&g_admission_mutex);
  return allowed;
}

static void Conversation_API_Release_Turn_Slot(void)
{
  pthread_mutex_lock(&g_admission_mutex);
  if (g_active_turns > 0)
    g_active_turns--;
  pthread_mutex_unlock(&g_admission_mutex);
}

static const char *Conversation_API_Request_Value(
    Seobeo_Request_Entry *p_request,
    const char *key)
{
  void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
  return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
}

static boolean Conversation_API_Is_Inference_Ready(void)
{
  return g_guest_inference_enabled;
}

/* UTC midnight for a Unix timestamp. */
static int64 conv__utc_window_start(int64 unix_ts)
{
  return (unix_ts / 86400LL) * 86400LL;
}

/* Build guest quota JSON into a fixed buffer (null-terminated). */
static boolean conv_guest_quota_cb(
    const char *guest_id,
    int64       current_unix,
    char       *json_out,
    size_t      json_capacity)
{
  Auth_Store *p_store = Auth_API_Get_Store();
  if (!p_store || !guest_id || !json_out || json_capacity == 0)
  {
    if (json_out && json_capacity > 0)
      strncpy(json_out, "null", json_capacity);
    return TRUE;
  }

  int64 window_start = conv__utc_window_start(current_unix);
  Auth_Store_Guest_Usage usage;
  memset(&usage, 0, sizeof(usage));
  Auth_Store_Guest_Get_Usage(p_store, guest_id, window_start, &usage);

  int64 turns_remaining =
      g_guest_daily_turns - usage.turns_used;
  if (turns_remaining < 0) turns_remaining = 0;
  int64 tokens_remaining =
      g_guest_daily_output_tokens - usage.output_tokens_used - usage.output_tokens_reserved;
  if (tokens_remaining < 0) tokens_remaining = 0;
  int64 resets_at = window_start + 86400LL;

  snprintf(
      json_out, json_capacity,
      "{\"turnsLimit\":%lld,\"turnsUsed\":%lld,\"turnsRemaining\":%lld,"
      "\"outputTokensLimit\":%lld,\"outputTokensUsed\":%lld,"
      "\"outputTokensReserved\":%lld,\"outputTokensRemaining\":%lld,"
      "\"resetsAt\":%lld}",
      (long long)g_guest_daily_turns,
      (long long)usage.turns_used,
      (long long)turns_remaining,
      (long long)g_guest_daily_output_tokens,
      (long long)usage.output_tokens_used,
      (long long)usage.output_tokens_reserved,
      (long long)tokens_remaining,
      (long long)resets_at);
  return TRUE;
}

static Dowa_JSON_Entry *Conversation_API_Parse_Body(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  const char *body = Conversation_API_Request_Value(p_request, "Body");
  if (!body)
    return NULL;
  Dowa_JSON_Value value = Dowa_JSON_Parse(
      body, (int32)strlen(body), p_arena);
  return value.type == DOWA_JSON_OBJECT
      ? (Dowa_JSON_Entry *)value.object_val
      : NULL;
}

static const char *Conversation_API_Query_Param(
    Seobeo_Request_Entry *p_request,
    const char *param_name,
    char *output,
    size_t output_capacity)
{
  const char *qs = Conversation_API_Request_Value(p_request, "QueryString");
  if (!qs || !param_name || !output || output_capacity == 0)
    return NULL;
  output[0] = '\0';
  size_t name_len = strlen(param_name);
  const char *p = qs;
  while (*p)
  {
    if (strncmp(p, param_name, name_len) == 0 && p[name_len] == '=')
    {
      p += name_len + 1;
      size_t i = 0;
      while (*p && *p != '&' && i < output_capacity - 1)
      {
        if (p[0] == '%' && p[1] && p[2])
        {
          char hex[3] = {p[1], p[2], '\0'};
          output[i++] = (char)(int)strtol(hex, NULL, 16);
          p += 3;
        }
        else if (*p == '+')
        {
          output[i++] = ' ';
          p++;
        }
        else
        {
          output[i++] = *p++;
        }
      }
      output[i] = '\0';
      return output[0] != '\0' ? output : NULL;
    }
    while (*p && *p != '&') p++;
    if (*p == '&') p++;
  }
  return NULL;
}

/*
 * Build a JSON response, optionally setting a guest cookie when one was
 * freshly issued by Auth_API_Resolve_Principal.
 */
static Seobeo_Request_Entry *Conversation_API_JSON_Response_With_Cookie(
    Dowa_Arena *p_arena,
    const char *status,
    const char *body,
    const char *new_guest_cookie)
{
  Seobeo_Request_Entry *p_response = NULL;
  Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena);
  Dowa_HashMap_Push_Arena(
      p_response, "content-type", "application/json; charset=utf-8", p_arena);
  Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena);
  Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena);
  if (new_guest_cookie && new_guest_cookie[0] != '\0')
    Dowa_HashMap_Push_Arena(
        p_response, "Set-Cookie", (char *)new_guest_cookie, p_arena);
  return p_response;
}

static Seobeo_Request_Entry *Conversation_API_JSON_Response(
    Dowa_Arena *p_arena,
    const char *status,
    const char *body)
{
  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, NULL);
}

static Seobeo_Request_Entry *Conversation_API_Error(
    Dowa_Arena *p_arena,
    const char *status,
    const char *code,
    const char *message)
{
  char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
  size_t capacity = strlen(code) + strlen(escaped) + 64;
  char *body = Dowa_Arena_Allocate(p_arena, capacity);
  snprintf(
      body,
      capacity,
      "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
      code,
      escaped);
  return Conversation_API_JSON_Response(p_arena, status, body);
}

static Seobeo_Request_Entry *Conversation_API_Error_With_Cookie(
    Dowa_Arena *p_arena,
    const char *status,
    const char *code,
    const char *message,
    const char *new_guest_cookie)
{
  char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
  size_t capacity = strlen(code) + strlen(escaped) + 64;
  char *body = Dowa_Arena_Allocate(p_arena, capacity);
  snprintf(
      body,
      capacity,
      "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
      code,
      escaped);
  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, new_guest_cookie);
}

/*
 * Resolve the principal for this request, optionally setting a new guest
 * cookie on the response when returned.
 * Returns FALSE only on internal error (treat as 500).
 */
static boolean Conversation_API_Resolve(
    Seobeo_Request_Entry *p_request,
    Auth_Principal *p_principal,
    char *new_guest_cookie,
    Dowa_Arena *p_arena)
{
  return Auth_API_Resolve_Principal(
      p_request, p_principal, p_arena,
      new_guest_cookie, CONV_GUEST_COOKIE_CAPACITY);
}

/*
 * Resolve an existing principal for mutation requests.
 * Does NOT create a new guest identity.
 * Returns FALSE on internal error (500); sets *p_found=FALSE when no
 * existing session/guest is present (caller must return 401).
 */
static boolean Conversation_API_Resolve_Existing(
    Seobeo_Request_Entry *p_request,
    Auth_Principal *p_principal,
    Dowa_Arena *p_arena,
    boolean *p_found)
{
  return Auth_API_Resolve_Existing_Principal(
      p_request, p_principal, p_arena, p_found);
}

/*
 * Map a resolved principal to a Conversation_Owner.
 * Always succeeds for USER and GUEST principals.
 */
static void Conversation_API_Owner_From_Principal(
    const Auth_Principal *p_principal,
    Conversation_Owner *p_owner)
{
  if (p_principal->kind == AUTH_PRINCIPAL_USER)
  {
    p_owner->kind = CONVERSATION_OWNER_KIND_USER;
    strncpy(p_owner->id, p_principal->user_id, sizeof(p_owner->id) - 1);
    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
  }
  else
  {
    p_owner->kind = CONVERSATION_OWNER_KIND_GUEST;
    strncpy(p_owner->id, p_principal->guest_id, sizeof(p_owner->id) - 1);
    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
  }
}

static boolean Conversation_API_Prompt_Profile_From_Principal(
    const Auth_Principal *p_principal,
    Inference_Prompt_Profile *p_profile)
{
  if (!p_principal || !p_profile)
    return FALSE;
  if (p_principal->kind == AUTH_PRINCIPAL_GUEST)
  {
    *p_profile = INFERENCE_PROMPT_PROFILE_PUBLIC_VISITOR;
    return TRUE;
  }
  if (p_principal->kind != AUTH_PRINCIPAL_USER)
    return FALSE;
  if (strcmp(p_principal->role, "member") == 0)
  {
    *p_profile = INFERENCE_PROMPT_PROFILE_INVITED_FRIEND;
    return TRUE;
  }
  if (strcmp(p_principal->role, "admin") == 0)
  {
    *p_profile = INFERENCE_PROMPT_PROFILE_JUNE_ADMIN;
    return TRUE;
  }
  return FALSE;
}

static void Conversation_API_Send_Stream_Error(
    Seobeo_Handle *p_handle,
    int status,
    const char *code,
    const char *message)
{
  char body[1024];
  int body_length = snprintf(
      body,
      sizeof(body),
      "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
      code,
      message);
  if (body_length < 0 || (size_t)body_length >= sizeof(body))
    return;
  char header[512];
  Seobeo_Web_Header_Generate(
      header,
      status,
      "application/json; charset=utf-8",
      body_length);
  Seobeo_Handle_Queue(
      p_handle, (const uint8 *)header, (uint32)strlen(header));
  Seobeo_Handle_Queue(
      p_handle, (const uint8 *)body, (uint32)body_length);
  Seobeo_Handle_Flush(p_handle);
}

static Pending_Turn *Conversation_API_Find_Pending(
    const char *request_id)
{
  for (Pending_Turn *p_turn = g_pending_turns;
       p_turn;
       p_turn = p_turn->p_next)
  {
    if (strcmp(p_turn->request_id, request_id) == 0)
      return p_turn;
  }
  return NULL;
}

static int32 Conversation_API_Send_Event(
    Pending_Turn *p_turn,
    const char *event_name,
    const char *json)
{
  Seobeo_SSE_Event event = {
    .event = event_name,
    .data = json,
    .retry_ms = SEOBEO_SSE_RETRY_NONE,
  };
  return Seobeo_SSE_Send(p_turn->p_stream, &event);
}

static void Conversation_API_Remove_Pending(Pending_Turn *p_turn)
{
  Pending_Turn **pp_current = &g_pending_turns;
  while (*pp_current)
  {
    if (*pp_current == p_turn)
    {
      *pp_current = p_turn->p_next;
      return;
    }
    pp_current = &(*pp_current)->p_next;
  }
}

static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn)
{
  /* Idempotence guard — called with g_pending_mutex held. */
  if (p_turn->finalized)
    return;
  p_turn->finalized = TRUE;

  /* Reconcile or release guest quota reservation before persistence. */
  if (p_turn->is_guest_reserved)
  {
    Auth_Store *p_auth_store = Auth_API_Get_Store();
    if (p_auth_store)
    {
      if (!p_turn->failed && !p_turn->aborted)
      {
        int64 actual = p_turn->has_usage_event
            ? p_turn->output_tokens
            : p_turn->reserved_output_tokens;
        Auth_Store_Guest_Reconcile(
            p_auth_store, p_turn->request_id, actual);
      }
      else
        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
    }
  }

  Conversation_Store_Result persistence;
  if (p_turn->failed || p_turn->aborted)
  {
    persistence = Conversation_Store_Fail_Turn(
        g_conversation_store,
        p_turn->conversation_id,
        p_turn->request_id,
        p_turn->error_message,
        p_turn->aborted);
  }
  else
  {
    persistence = Conversation_Store_Complete_Turn(
        g_conversation_store,
        p_turn->conversation_id,
        p_turn->request_id,
        p_turn->content ? p_turn->content : "",
        p_turn->input_tokens,
        p_turn->output_tokens);
  }
  if (persistence != CONVERSATION_STORE_OK)
  {
    p_turn->failed = TRUE;
    snprintf(
        p_turn->error_message,
        sizeof(p_turn->error_message),
        "Unable to persist completed turn");
    Conversation_Store_Fail_Turn(
        g_conversation_store,
        p_turn->conversation_id,
        p_turn->request_id,
        p_turn->error_message,
        FALSE);
    Conversation_API_Send_Event(
        p_turn,
        "turn.error",
        "{\"code\":\"storage_failed\","
        "\"message\":\"Unable to persist completed turn\"}");
  }

  char done[256];
  snprintf(
      done,
      sizeof(done),
      "{\"request_id\":\"%s\",\"failed\":%s,\"aborted\":%s}",
      p_turn->request_id,
      p_turn->failed ? "true" : "false",
      p_turn->aborted ? "true" : "false");
  Conversation_API_Send_Event(p_turn, "turn.done", done);
  Conversation_API_Remove_Pending(p_turn);
  Seobeo_SSE_Close(p_turn->p_stream);
  Seobeo_SSE_Release(p_turn->p_stream);
  Conversation_API_Release_Turn_Slot();
  free(p_turn->content);
  free(p_turn);
}

static void Conversation_API_Handle_Inference_Event(
    const Inference_Event *p_event,
    void *p_user_data)
{
  (void)p_user_data;
  if (strcmp(p_event->type, "bridge.closed") == 0)
  {
    pthread_mutex_lock(&g_pending_mutex);
    while (g_pending_turns)
    {
      Pending_Turn *p_turn = g_pending_turns;
      p_turn->failed = TRUE;
      snprintf(
          p_turn->error_message,
          sizeof(p_turn->error_message),
          "Inference sidecar connection closed");
      Conversation_API_Send_Event(
          p_turn,
          "turn.error",
          "{\"code\":\"sidecar_closed\","
          "\"message\":\"Inference sidecar connection closed\"}");
      Conversation_API_Finalize_Pending(p_turn);
    }
    pthread_mutex_unlock(&g_pending_mutex);
    return;
  }
  if (!p_event->request_id || p_event->request_id[0] == '\0')
    return;

  boolean should_abort = FALSE;
  char abort_request_id[37] = {0};
  char abort_conversation_id[37] = {0};
  pthread_mutex_lock(&g_pending_mutex);
  Pending_Turn *p_turn = Conversation_API_Find_Pending(p_event->request_id);
  if (!p_turn)
  {
    pthread_mutex_unlock(&g_pending_mutex);
    return;
  }

  if (!Conversation_API_Is_Handled_Event(p_event->type))
  {
    if (!Conversation_API_Is_Safe_Event_Name(p_event->type) ||
        !p_event->raw_json ||
        p_event->raw_json_length == 0 ||
        p_event->raw_json_length > CONVERSATION_CUSTOM_EVENT_MAX)
    {
      p_turn->failed = TRUE;
      snprintf(
          p_turn->error_message,
          sizeof(p_turn->error_message),
          "Invalid custom inference event");
      Conversation_API_Send_Event(
          p_turn,
          "turn.error",
          "{\"code\":\"invalid_custom_event\","
          "\"message\":\"Invalid custom inference event\"}");
      Conversation_API_Finalize_Pending(p_turn);
    }
    else if (Conversation_API_Send_Event(
                 p_turn, p_event->type, p_event->raw_json) < 0)
    {
      p_turn->aborted = TRUE;
      should_abort = TRUE;
      snprintf(
          abort_request_id,
          sizeof(abort_request_id),
          "%s",
          p_turn->request_id);
      snprintf(
          abort_conversation_id,
          sizeof(abort_conversation_id),
          "%s",
          p_turn->conversation_id);
    }
    pthread_mutex_unlock(&g_pending_mutex);
    if (should_abort)
      Inference_Bridge_Abort_Turn(
          g_inference_bridge, abort_request_id, abort_conversation_id);
    return;
  }

  size_t event_text_length =
      strlen(p_event->delta ? p_event->delta : "") +
      strlen(p_event->content ? p_event->content : "") +
      strlen(p_event->error_code ? p_event->error_code : "") +
      strlen(p_event->error_message ? p_event->error_message : "");
  if (event_text_length > (((size_t)-1) - 8192) / 12)
  {
    p_turn->failed = TRUE;
    snprintf(
        p_turn->error_message,
        sizeof(p_turn->error_message),
        "Inference event exceeded memory limit");
    Conversation_API_Send_Event(
        p_turn,
        "turn.error",
        "{\"code\":\"event_too_large\","
        "\"message\":\"Inference event exceeded memory limit\"}");
    Conversation_API_Finalize_Pending(p_turn);
    pthread_mutex_unlock(&g_pending_mutex);
    return;
  }
  Dowa_Arena *p_arena = Dowa_Arena_Create(
      event_text_length * 12 + 8192);
  if (!p_arena)
  {
    p_turn->failed = TRUE;
    snprintf(
        p_turn->error_message,
        sizeof(p_turn->error_message),
        "Unable to allocate inference event");
    Conversation_API_Send_Event(
        p_turn,
        "turn.error",
        "{\"code\":\"allocation_failed\","
        "\"message\":\"Unable to allocate inference event\"}");
    Conversation_API_Finalize_Pending(p_turn);
    pthread_mutex_unlock(&g_pending_mutex);
    return;
  }

  if (strcmp(p_event->type, "turn.accepted") == 0)
  {
    char accepted[128];
    snprintf(
        accepted,
        sizeof(accepted),
        "{\"request_id\":\"%s\"}",
        p_turn->request_id);
    Conversation_API_Send_Event(p_turn, "turn.accepted", accepted);
  }
  else if (strcmp(p_event->type, "assistant.delta") == 0)
  {
    size_t delta_length = strlen(p_event->delta);
    if (p_turn->content_length + delta_length > CONVERSATION_RESPONSE_MAX)
    {
      p_turn->failed = TRUE;
      snprintf(
          p_turn->error_message,
          sizeof(p_turn->error_message),
          "Assistant response exceeded limit");
      should_abort = TRUE;
      snprintf(abort_request_id, sizeof(abort_request_id), "%s",
               p_turn->request_id);
      snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
               p_turn->conversation_id);
      char error[256];
      snprintf(
          error,
          sizeof(error),
          "{\"request_id\":\"%s\",\"code\":\"response_too_large\","
          "\"message\":\"Assistant response exceeded limit\"}",
          p_turn->request_id);
      Conversation_API_Send_Event(p_turn, "turn.error", error);
    }
    else
    {
      if (p_turn->content_length + delta_length + 1 >
          p_turn->content_capacity)
      {
        size_t next_capacity = p_turn->content_capacity
            ? p_turn->content_capacity * 2
            : 4096;
        while (next_capacity <
               p_turn->content_length + delta_length + 1)
          next_capacity *= 2;
        char *next = realloc(p_turn->content, next_capacity);
        if (!next)
        {
          p_turn->failed = TRUE;
          snprintf(
              p_turn->error_message,
              sizeof(p_turn->error_message),
              "Unable to buffer assistant response");
        }
        else
        {
          p_turn->content = next;
          p_turn->content_capacity = next_capacity;
        }
      }
      if (!p_turn->failed)
      {
        memcpy(
            p_turn->content + p_turn->content_length,
            p_event->delta,
            delta_length);
        p_turn->content_length += delta_length;
        p_turn->content[p_turn->content_length] = '\0';
        char *delta = Dowa_JSON_Escape_String(
            p_event->delta, delta_length, p_arena);
        size_t capacity = delta ? strlen(delta) + 128 : 0;
        char *json = capacity
            ? Dowa_Arena_Allocate(p_arena, capacity)
            : NULL;
        if (!delta || !json)
        {
          p_turn->failed = TRUE;
          snprintf(
              p_turn->error_message,
              sizeof(p_turn->error_message),
              "Unable to serialize assistant delta");
          Conversation_API_Send_Event(
              p_turn,
              "turn.error",
              "{\"code\":\"serialize_failed\","
              "\"message\":\"Unable to serialize assistant delta\"}");
          should_abort = TRUE;
          snprintf(abort_request_id, sizeof(abort_request_id), "%s",
                   p_turn->request_id);
          snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
                   p_turn->conversation_id);
        }
        else
        {
          snprintf(
              json,
              capacity,
              "{\"request_id\":\"%s\",\"delta\":\"%s\"}",
              p_turn->request_id,
              delta);
          if (Conversation_API_Send_Event(
                  p_turn, "assistant.delta", json) < 0)
          {
            p_turn->aborted = TRUE;
            should_abort = TRUE;
            snprintf(abort_request_id, sizeof(abort_request_id), "%s",
                     p_turn->request_id);
            snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
                     p_turn->conversation_id);
          }
        }
      }
    }
  }
  else if (strcmp(p_event->type, "assistant.completed") == 0)
  {
    size_t content_length = strlen(p_event->content);
    if (content_length > CONVERSATION_RESPONSE_MAX)
    {
      p_turn->failed = TRUE;
      snprintf(
          p_turn->error_message,
          sizeof(p_turn->error_message),
          "Assistant response exceeded limit");
      char error[256];
      snprintf(
          error,
          sizeof(error),
          "{\"request_id\":\"%s\",\"code\":\"response_too_large\","
          "\"message\":\"Assistant response exceeded limit\"}",
          p_turn->request_id);
      Conversation_API_Send_Event(p_turn, "turn.error", error);
    }
    else
    {
      char *content = realloc(p_turn->content, content_length + 1);
      if (content)
      {
        p_turn->content = content;
        p_turn->content_capacity = content_length + 1;
        memcpy(p_turn->content, p_event->content, content_length + 1);
        p_turn->content_length = content_length;
      }
      else
      {
        p_turn->failed = TRUE;
        snprintf(
            p_turn->error_message,
            sizeof(p_turn->error_message),
            "Unable to buffer assistant response");
      }
      if (!p_turn->failed)
      {
        char *escaped = Dowa_JSON_Escape_String(
            p_event->content, content_length, p_arena);
        size_t capacity = escaped ? strlen(escaped) + 128 : 0;
        char *json = capacity
            ? Dowa_Arena_Allocate(p_arena, capacity)
            : NULL;
        if (!escaped || !json)
        {
          p_turn->failed = TRUE;
          snprintf(
              p_turn->error_message,
              sizeof(p_turn->error_message),
              "Unable to serialize assistant response");
          Conversation_API_Send_Event(
              p_turn,
              "turn.error",
              "{\"code\":\"serialize_failed\","
              "\"message\":\"Unable to serialize assistant response\"}");
        }
        else
        {
          snprintf(
              json,
              capacity,
              "{\"request_id\":\"%s\",\"content\":\"%s\"}",
              p_turn->request_id,
              escaped);
          Conversation_API_Send_Event(p_turn, "assistant.completed", json);
        }
      }
    }
  }
  else if (strcmp(p_event->type, "assistant.usage") == 0)
  {
    p_turn->input_tokens = p_event->input_tokens;
    p_turn->output_tokens = p_event->output_tokens;
    p_turn->has_usage_event = TRUE;
    char usage[256];
    snprintf(
        usage,
        sizeof(usage),
        "{\"request_id\":\"%s\",\"input_tokens\":%lld,"
        "\"output_tokens\":%lld}",
        p_turn->request_id,
        (long long)p_turn->input_tokens,
        (long long)p_turn->output_tokens);
    Conversation_API_Send_Event(p_turn, "assistant.usage", usage);
  }
  else if (strcmp(p_event->type, "turn.error") == 0)
  {
    p_turn->failed = TRUE;
    snprintf(
        p_turn->error_message,
        sizeof(p_turn->error_message),
        "%s",
        p_event->error_message);
    char *code = Dowa_JSON_Escape_String(
        p_event->error_code, 0, p_arena);
    char *message = Dowa_JSON_Escape_String(
        p_event->error_message, 0, p_arena);
    size_t capacity = code && message
        ? strlen(code) + strlen(message) + 160
        : 0;
    char *json = capacity
        ? Dowa_Arena_Allocate(p_arena, capacity)
        : NULL;
    if (!code || !message || !json)
    {
      Conversation_API_Send_Event(
          p_turn,
          "turn.error",
          "{\"code\":\"serialize_failed\","
          "\"message\":\"Unable to serialize inference error\"}");
    }
    else
    {
      snprintf(
          json,
          capacity,
          "{\"request_id\":\"%s\",\"code\":\"%s\",\"message\":\"%s\"}",
          p_turn->request_id,
          code,
          message);
      Conversation_API_Send_Event(p_turn, "turn.error", json);
    }
  }
  else if (strcmp(p_event->type, "turn.done") == 0)
  {
    p_turn->failed = p_turn->failed || p_event->failed;
    p_turn->aborted = p_turn->aborted || p_event->aborted;
    Conversation_API_Finalize_Pending(p_turn);
  }

  Dowa_Arena_Free(p_arena);
  pthread_mutex_unlock(&g_pending_mutex);
  if (should_abort)
    Inference_Bridge_Abort_Turn(
        g_inference_bridge, abort_request_id, abort_conversation_id);
}

/* ------------------------------------------------------------------ */
/* HTTP handler helpers                                                  */
/* ------------------------------------------------------------------ */

static boolean Conversation_API_Append(
    char *output,
    size_t capacity,
    size_t *p_offset,
    const char *format,
    ...)
{
  if (*p_offset >= capacity)
    return FALSE;
  va_list args;
  va_start(args, format);
  int written = vsnprintf(
      output + *p_offset, capacity - *p_offset, format, args);
  va_end(args);
  if (written < 0 || (size_t)written >= capacity - *p_offset)
    return FALSE;
  *p_offset += (size_t)written;
  return TRUE;
}

/* ------------------------------------------------------------------ */
/* GET /api/conversations?cursor=<ts>_<id>&limit=20                    */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_List(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (principal.must_change_password)
    return Conversation_API_Error_With_Cookie(
        p_arena, "403", "password_change_required",
        "Password change required", new_guest_cookie);

  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  /* Parse cursor and limit from query string */
  char cursor_buf[80] = {0};
  char limit_buf[8] = {0};
  Conversation_API_Query_Param(p_request, "cursor", cursor_buf, sizeof(cursor_buf));
  Conversation_API_Query_Param(p_request, "limit", limit_buf, sizeof(limit_buf));

  int64 cursor_updated_at = 0;
  char cursor_id[37] = {0};
  if (cursor_buf[0] != '\0')
  {
    /* cursor format: <updated_at>_<uuid> */
    const char *underscore = strchr(cursor_buf, '_');
    if (!underscore || underscore == cursor_buf ||
        strlen(underscore + 1) != 36)
      return Conversation_API_Error_With_Cookie(
          p_arena, "400", "invalid_cursor", "Cursor format invalid",
          new_guest_cookie);
    char ts_part[32] = {0};
    size_t ts_len = (size_t)(underscore - cursor_buf);
    if (ts_len >= sizeof(ts_part))
      return Conversation_API_Error_With_Cookie(
          p_arena, "400", "invalid_cursor", "Cursor format invalid",
          new_guest_cookie);
    memcpy(ts_part, cursor_buf, ts_len);
    cursor_updated_at = (int64)atoll(ts_part);
    if (cursor_updated_at <= 0)
      return Conversation_API_Error_With_Cookie(
          p_arena, "400", "invalid_cursor", "Cursor timestamp invalid",
          new_guest_cookie);
    strncpy(cursor_id, underscore + 1, 36);
    cursor_id[36] = '\0';
  }

  int32 limit = 20;
  if (limit_buf[0] != '\0')
  {
    int parsed = atoi(limit_buf);
    if (parsed >= 1 && parsed <= 50)
      limit = parsed;
    else if (parsed > 50)
      limit = 50;
  }

  Conversation_Summary *summaries = NULL;
  int32 count = 0;
  Conversation_Store_Result result = Conversation_Store_List(
      g_conversation_store, &owner,
      cursor_updated_at, cursor_id[0] ? cursor_id : NULL,
      limit, &summaries, &count, p_arena);
  if (result != CONVERSATION_STORE_OK)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "list_failed", "Unable to list conversations",
        new_guest_cookie);

  /* Estimate response size */
  size_t capacity = 128;
  for (int32 i = 0; i < count; i++)
  {
    Conversation_Summary *s = &summaries[i];
    capacity += (s->title ? strlen(s->title) : 0) * 6 +
                (s->last_message_preview ? strlen(s->last_message_preview) : 0) * 6 +
                256;
  }
  char *body = Dowa_Arena_Allocate(p_arena, capacity);
  if (!body)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
        new_guest_cookie);

  size_t offset = 0;
  if (!Conversation_API_Append(body, capacity, &offset,
                               "{\"conversations\":["))
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response too large",
        new_guest_cookie);

  for (int32 i = 0; i < count; i++)
  {
    Conversation_Summary *s = &summaries[i];
    char *esc_title = Dowa_JSON_Escape_String(
        s->title ? s->title : "", 0, p_arena);
    char *esc_preview = Dowa_JSON_Escape_String(
        s->last_message_preview ? s->last_message_preview : "", 0, p_arena);
    if (!Conversation_API_Append(
            body, capacity, &offset,
            "%s{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
            "\"created_at\":%lld,\"updated_at\":%lld,"
            "\"turn_count\":%lld,\"last_message_preview\":\"%s\"}",
            i == 0 ? "" : ",",
            s->id ? s->id : "",
            esc_title ? esc_title : "",
            s->status ? s->status : "",
            (long long)s->created_at,
            (long long)s->updated_at,
            (long long)s->turn_count,
            esc_preview ? esc_preview : ""))
      return Conversation_API_Error_With_Cookie(
          p_arena, "500", "serialize_failed", "Response too large",
          new_guest_cookie);
  }

  /* Next cursor: last item's (updated_at, id) */
  char cursor_out[80] = "null";
  if (count == limit && count > 0)
  {
    Conversation_Summary *last = &summaries[count - 1];
    if (last->id)
      snprintf(cursor_out, sizeof(cursor_out), "\"%lld_%s\"",
               (long long)last->updated_at, last->id);
  }
  if (!Conversation_API_Append(body, capacity, &offset,
                               "],\"cursor\":%s}", cursor_out))
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response too large",
        new_guest_cookie);

  return Conversation_API_JSON_Response_With_Cookie(
      p_arena, "200", body, new_guest_cookie);
}

/* ------------------------------------------------------------------ */
/* POST /api/conversations                                              */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Create(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  boolean found = FALSE;
  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (!found)
    return Conversation_API_Error(
        p_arena, "401", "auth_required", "Bootstrap session first");
  if (principal.must_change_password)
    return Conversation_API_Error(
        p_arena, "403", "password_change_required",
        "Password change required");
  if (!Auth_API_Verify_CSRF(p_request, &principal))
    return Conversation_API_Error(
        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");

  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  const char *title = "";
  const char *body = Conversation_API_Request_Value(p_request, "Body");
  if (body && body[0] != '\0')
  {
    Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
    if (!object)
      return Conversation_API_Error(
          p_arena, "400", "invalid_json", "Request body must be a JSON object");
    char *parsed_title = Dowa_JSON_Get_String(object, "title");
    if (parsed_title)
      title = parsed_title;
  }
  if (strlen(title) > CONVERSATION_TITLE_MAX)
    return Conversation_API_Error(
        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");

  char conversation_id[37];
  if (Conversation_Store_Create_Owned(
          g_conversation_store, title, &owner,
          conversation_id) != CONVERSATION_STORE_OK)
    return Conversation_API_Error(
        p_arena, "500", "create_failed", "Unable to create conversation");

  char *response_body = Dowa_Arena_Allocate(p_arena, 64);
  snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
  return Conversation_API_JSON_Response(p_arena, "201", response_body);
}

/* ------------------------------------------------------------------ */
/* GET /api/conversations/:conversation_id                              */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Get(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (principal.must_change_password)
    return Conversation_API_Error_With_Cookie(
        p_arena, "403", "password_change_required",
        "Password change required", new_guest_cookie);

  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  const char *conversation_id =
      Conversation_API_Request_Value(p_request, ":conversation_id");
  if (!conversation_id)
    return Conversation_API_Error_With_Cookie(
        p_arena, "400", "missing_id", "Conversation ID is required",
        new_guest_cookie);

  Conversation_Record record;
  Conversation_Store_Result result = Conversation_Store_Get_Owned(
      g_conversation_store, conversation_id, &owner, &record, p_arena);
  if (result == CONVERSATION_STORE_NOT_FOUND)
    return Conversation_API_Error_With_Cookie(
        p_arena, "404", "not_found", "Conversation not found",
        new_guest_cookie);
  if (result != CONVERSATION_STORE_OK)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "load_failed", "Unable to load conversation",
        new_guest_cookie);

  if (!record.id || !record.title || !record.status)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "load_failed", "Conversation data exceeded limits",
        new_guest_cookie);
  size_t history_size = strlen(record.title) + strlen(record.status);
  for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
  {
    Conversation_Turn *p_turn = &record.turns[i];
    if (!p_turn->role || !p_turn->content || !p_turn->status ||
        !p_turn->request_id || !p_turn->error_message)
      return Conversation_API_Error_With_Cookie(
          p_arena, "500", "load_failed", "Conversation data exceeded limits",
          new_guest_cookie);
    history_size +=
        strlen(p_turn->role) + strlen(p_turn->content) +
        strlen(p_turn->status) + strlen(p_turn->request_id) +
        strlen(p_turn->error_message);
    if (history_size > CONVERSATION_HISTORY_MAX)
      return Conversation_API_Error_With_Cookie(
          p_arena,
          "413",
          "history_too_large",
          "Conversation history exceeds response limit",
          new_guest_cookie);
  }
  char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena);
  if (!escaped_title)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Unable to serialize conversation",
        new_guest_cookie);
  size_t capacity = strlen(escaped_title) + 512;
  for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
  {
    Conversation_Turn *p_turn = &record.turns[i];
    capacity +=
        (strlen(p_turn->role) + strlen(p_turn->content) +
         strlen(p_turn->status) + strlen(p_turn->request_id) +
         strlen(p_turn->error_message)) * 6 + 320;
  }
  char *body = Dowa_Arena_Allocate(p_arena, capacity);
  if (!body)
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
        new_guest_cookie);
  size_t offset = 0;
  if (!Conversation_API_Append(
          body,
          capacity,
          &offset,
          "{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
          "\"created_at\":%lld,\"updated_at\":%lld,\"turns\":[",
          record.id,
          escaped_title,
          record.status,
          (long long)record.created_at,
          (long long)record.updated_at))
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response too large",
        new_guest_cookie);

  for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
  {
    Conversation_Turn *p_turn = &record.turns[i];
    char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena);
    char *content = Dowa_JSON_Escape_String(p_turn->content, 0, p_arena);
    char *status = Dowa_JSON_Escape_String(p_turn->status, 0, p_arena);
    char *request_id = Dowa_JSON_Escape_String(
        p_turn->request_id, 0, p_arena);
    char *error = Dowa_JSON_Escape_String(
        p_turn->error_message, 0, p_arena);
    if (!Conversation_API_Append(
            body,
            capacity,
            &offset,
            "%s{\"id\":%lld,\"sequence\":%lld,\"role\":\"%s\","
            "\"content\":\"%s\",\"status\":\"%s\","
            "\"request_id\":\"%s\",\"error\":\"%s\","
            "\"input_tokens\":%lld,\"output_tokens\":%lld,"
            "\"created_at\":%lld,\"completed_at\":%lld}",
            i == 0 ? "" : ",",
            (long long)p_turn->id,
            (long long)p_turn->sequence,
            role,
            content,
            status,
            request_id,
            error,
            (long long)p_turn->input_tokens,
            (long long)p_turn->output_tokens,
            (long long)p_turn->created_at,
            (long long)p_turn->completed_at))
      return Conversation_API_Error_With_Cookie(
          p_arena, "500", "serialize_failed", "Response too large",
          new_guest_cookie);
  }
  if (!Conversation_API_Append(body, capacity, &offset, "]}"))
    return Conversation_API_Error_With_Cookie(
        p_arena, "500", "serialize_failed", "Response too large",
        new_guest_cookie);
  return Conversation_API_JSON_Response_With_Cookie(
      p_arena, "200", body, new_guest_cookie);
}

/* ------------------------------------------------------------------ */
/* PATCH /api/conversations/:conversation_id                            */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Update(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  boolean found = FALSE;
  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (!found)
    return Conversation_API_Error(
        p_arena, "401", "auth_required", "Bootstrap session first");
  if (principal.must_change_password)
    return Conversation_API_Error(
        p_arena, "403", "password_change_required",
        "Password change required");
  if (!Auth_API_Verify_CSRF(p_request, &principal))
    return Conversation_API_Error(
        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");

  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  const char *conversation_id =
      Conversation_API_Request_Value(p_request, ":conversation_id");
  Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
  char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL;
  if (!conversation_id || !title)
    return Conversation_API_Error(
        p_arena, "400", "invalid_request", "Conversation ID and title required");
  if (strlen(title) > CONVERSATION_TITLE_MAX)
    return Conversation_API_Error(
        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");

  Conversation_Store_Result result = Conversation_Store_Update_Title_Owned(
      g_conversation_store, conversation_id, &owner, title);
  if (result == CONVERSATION_STORE_NOT_FOUND)
    return Conversation_API_Error(
        p_arena, "404", "not_found", "Conversation not found");
  if (result != CONVERSATION_STORE_OK)
    return Conversation_API_Error(
        p_arena, "500", "update_failed", "Unable to update conversation");
  return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
}

/* ------------------------------------------------------------------ */
/* DELETE /api/conversations/:conversation_id                           */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Delete(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  boolean found = FALSE;
  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (!found)
    return Conversation_API_Error(
        p_arena, "401", "auth_required", "Bootstrap session first");
  if (principal.must_change_password)
    return Conversation_API_Error(
        p_arena, "403", "password_change_required",
        "Password change required");
  if (!Auth_API_Verify_CSRF(p_request, &principal))
    return Conversation_API_Error(
        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");

  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  const char *conversation_id =
      Conversation_API_Request_Value(p_request, ":conversation_id");
  if (!conversation_id)
    return Conversation_API_Error(
        p_arena, "400", "missing_id", "Conversation ID is required");

  Conversation_Store_Result result = Conversation_Store_Delete_Owned(
      g_conversation_store, conversation_id, &owner);
  if (result == CONVERSATION_STORE_NOT_FOUND)
    return Conversation_API_Error(
        p_arena, "404", "not_found", "Conversation not found");
  if (result != CONVERSATION_STORE_OK)
    return Conversation_API_Error(
        p_arena, "500", "delete_failed", "Unable to delete conversation");

  if (Inference_Bridge_Is_Ready(g_inference_bridge))
  {
    char request_id[37];
    if (Conversation_Store_Generate_UUID(request_id))
      Inference_Bridge_Delete_Conversation(
          g_inference_bridge, request_id, conversation_id);
  }

  Seobeo_Request_Entry *p_response = NULL;
  Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena);
  Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena);
  return p_response;
}

/* ------------------------------------------------------------------ */
/* POST /api/conversations/claim  (body: {"conversationId":"<uuid>"}) */
/* ID stays in request body — never appears in URL or request log.    */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Claim_Body(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  if (!g_conversation_store)
    return Conversation_API_Error(
        p_arena, "503", "store_unavailable", "Conversation store unavailable");

  Auth_Principal principal;
  boolean found = FALSE;
  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
  if (!found)
    return Conversation_API_Error(
        p_arena, "401", "auth_required", "Bootstrap session first");
  /* Guests may not claim; forced-password-change users blocked */
  if (principal.kind != AUTH_PRINCIPAL_USER)
    return Conversation_API_Error(
        p_arena, "403", "forbidden", "Must be authenticated to claim");
  if (principal.must_change_password)
    return Conversation_API_Error(
        p_arena, "403", "password_change_required",
        "Password change required");
  if (!Auth_API_Verify_CSRF(p_request, &principal))
    return Conversation_API_Error(
        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");

  Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
  if (!object)
    return Conversation_API_Error(
        p_arena, "400", "invalid_json", "Request body must be a JSON object");
  const char *conversation_id = Dowa_JSON_Get_String(object, "conversationId");
  if (!conversation_id || conversation_id[0] == '\0')
    return Conversation_API_Error(
        p_arena, "400", "missing_id", "conversationId is required");
  if (strlen(conversation_id) > 36)
    return Conversation_API_Error(
        p_arena, "400", "invalid_id", "conversationId is invalid");

  Conversation_Store_Result result = Conversation_Store_Claim_Legacy(
      g_conversation_store, conversation_id, principal.user_id);
  if (result == CONVERSATION_STORE_NOT_FOUND)
    return Conversation_API_Error(
        p_arena, "404", "not_found", "Conversation not found");
  if (result == CONVERSATION_STORE_CONFLICT)
    return Conversation_API_Error(
        p_arena, "409", "already_owned", "Conversation is already owned");
  if (result != CONVERSATION_STORE_OK)
    return Conversation_API_Error(
        p_arena, "500", "claim_failed", "Unable to claim conversation");

  return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
}

/* ------------------------------------------------------------------ */
/* GET /api/inference/health                                            */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *Conversation_API_Health(
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  (void)p_request;
  boolean ready =
      Conversation_API_Is_Inference_Ready() &&
      g_conversation_store &&
      Inference_Bridge_Is_Ready(g_inference_bridge);
  return Conversation_API_JSON_Response(
      p_arena,
      ready ? "200" : "503",
      ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}");
}

/* ------------------------------------------------------------------ */
/* POST /api/conversations/:conversation_id/turns  (streaming)          */
/* ------------------------------------------------------------------ */

/*
 * SSE client-disconnect callback.
 * Called by Seobeo_SSE_Server_Detach_Handle after g_sse_mutex is released.
 * Finds the pending turn for the disconnected stream and finalizes it once.
 * Must NOT hold g_sse_mutex when called; acquires g_pending_mutex.
 */
static void Conversation_API_On_SSE_Detach(
    Seobeo_SSE_Stream *p_stream,
    void              *ctx)
{
  (void)ctx;
  pthread_mutex_lock(&g_pending_mutex);
  for (Pending_Turn *p_turn = g_pending_turns;
       p_turn;
       p_turn = p_turn->p_next)
  {
    if (p_turn->p_stream == p_stream && !p_turn->finalized)
    {
      p_turn->aborted = TRUE;
      snprintf(p_turn->error_message, sizeof(p_turn->error_message),
               "Client disconnected");
      Conversation_API_Finalize_Pending(p_turn);
      break;
    }
  }
  pthread_mutex_unlock(&g_pending_mutex);
}

static void Conversation_API_Turn_Stream(
    Seobeo_Handle *p_handle,
    Seobeo_Request_Entry *p_request,
    Dowa_Arena *p_arena)
{
  /* Resolve existing identity first so we can apply controls per-principal. */
  Auth_Principal principal;
  boolean found = FALSE;
  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 500, "internal_error", "Session error");
    return;
  }
  Inference_Prompt_Profile prompt_profile;
  if (!Conversation_API_Prompt_Profile_From_Principal(
          &principal, &prompt_profile))
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 403, "invalid_role", "Conversation role is not supported");
    return;
  }
  if (!found)
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 401, "auth_required", "Bootstrap session first");
    return;
  }
  if (principal.must_change_password)
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 403, "password_change_required",
        "Password change required");
    return;
  }
  if (!Auth_API_Verify_CSRF(p_request, &principal))
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 403, "csrf_invalid", "Same-origin and CSRF token required");
    return;
  }

  /* Guests require inference to be explicitly enabled; authenticated users
   * always proceed subject to the global bridge-ready check below. */
  if (principal.kind == AUTH_PRINCIPAL_GUEST &&
      !Conversation_API_Is_Inference_Ready())
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 503, "inference_disabled", "Inference API is disabled");
    return;
  }
  if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge))
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 503, "inference_unavailable", "Inference runtime unavailable");
    return;
  }

  /* Map principal to owner — copy IDs before arena may expire (req 8) */
  Conversation_Owner owner;
  Conversation_API_Owner_From_Principal(&principal, &owner);

  const char *conversation_id =
      Conversation_API_Request_Value(p_request, ":conversation_id");
  Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
  char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL;
  if (!conversation_id || !prompt || prompt[0] == '\0')
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 400, "invalid_request", "Conversation ID and prompt required");
    return;
  }
  size_t prompt_length = strlen(prompt);
  if (prompt_length > CONVERSATION_PROMPT_MAX)
  {
    Conversation_API_Send_Stream_Error(
        p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB");
    return;
  }
  if (!Conversation_API_Acquire_Turn_Slot())
  {
    /* Temporary capacity limit — advise client to retry in 5 s. */
    static const char rate_body[] =
        "{\"error\":{\"code\":\"rate_limited\","
        "\"message\":\"Inference capacity exhausted\"}}";
    char rate_header[512];
    snprintf(rate_header, sizeof(rate_header),
        "HTTP/1.1 429 Too Many Requests\r\n"
        "Content-Type: application/json; charset=utf-8\r\n"
        "Content-Length: %zu\r\n"
        "Retry-After: 5\r\n"
        "Connection: close\r\n"
        "\r\n",
        sizeof(rate_body) - 1);
    Seobeo_Handle_Queue(
        p_handle, (const uint8 *)rate_header, (uint32)strlen(rate_header));
    Seobeo_Handle_Queue(
        p_handle, (const uint8 *)rate_body,
        (uint32)(sizeof(rate_body) - 1));
    Seobeo_Handle_Flush(p_handle);
    return;
  }

  char request_id[37];
  if (!Conversation_Store_Generate_UUID(request_id))
  {
    Conversation_API_Release_Turn_Slot();
    Conversation_API_Send_Stream_Error(
        p_handle, 500, "id_failed", "Unable to create request ID");
    return;
  }

  /* --- Load owner-verified conversation record for transcript history --- */
  /* Done before quota reservation: if the conversation is missing or
   * not owned by this principal we fail fast with no quota side-effects. */
  Conversation_Record hist_record;
  memset(&hist_record, 0, sizeof(hist_record));
  {
    Conversation_Store_Result hist_result = Conversation_Store_Get_Owned(
        g_conversation_store, conversation_id, &owner, &hist_record, p_arena);
    if (hist_result == CONVERSATION_STORE_NOT_FOUND)
    {
      Conversation_API_Release_Turn_Slot();
      Conversation_API_Send_Stream_Error(
          p_handle, 404, "not_found", "Conversation not found");
      return;
    }
    if (hist_result != CONVERSATION_STORE_OK)
    {
      Conversation_API_Release_Turn_Slot();
      Conversation_API_Send_Stream_Error(
          p_handle, 500, "history_load_failed", "Unable to load conversation");
      return;
    }
  }

  /* Build bounded history: last ≤20 qualifying turns from the persisted
   * record.  Include non-empty user turns and completed, non-empty assistant
   * turns only.  The new prompt is not yet part of history. */
  Inference_Bridge_History_Message hist_msgs[INFERENCE_BRIDGE_HISTORY_MAX];
  uint32 hist_count = 0;
  {
    /* Collect qualifying turn indices in a sliding window of HIST_MAX. */
    size_t qidx[INFERENCE_BRIDGE_HISTORY_MAX];
    uint32 qcount = 0;
    size_t num_turns = Dowa_Array_Length(hist_record.turns);
    for (size_t ti = 0; ti < num_turns; ti++)
    {
      Conversation_Turn *t = &hist_record.turns[ti];
      if (!t->role || !t->content || !t->status)
        continue;
      boolean is_user = strcmp(t->role, "user") == 0;
      boolean is_asst = strcmp(t->role, "assistant") == 0;
      if (!is_user && !is_asst)
        continue;
      if (t->content[0] == '\0')
        continue;
      if (is_asst && strcmp(t->status, "complete") != 0)
        continue;
      if (qcount < INFERENCE_BRIDGE_HISTORY_MAX)
        qidx[qcount++] = ti;
      else
      {
        /* Shift window left to keep the most-recent HIST_MAX entries. */
        for (uint32 k = 0; k + 1 < INFERENCE_BRIDGE_HISTORY_MAX; k++)
          qidx[k] = qidx[k + 1];
        qidx[INFERENCE_BRIDGE_HISTORY_MAX - 1] = ti;
      }
    }
    hist_count = qcount;
    for (uint32 i = 0; i < hist_count; i++)
    {
      /* Strings are in p_arena; they live through the synchronous bridge write. */
      hist_msgs[i].role    = hist_record.turns[qidx[i]].role;
      hist_msgs[i].content = hist_record.turns[qidx[i]].content;
    }
  }

  /* --- Guest quota reservation (before persisting turn) --- */
  boolean is_guest_reserved = FALSE;
  char    quota_guest_id[37] = {0};
  if (principal.kind == AUTH_PRINCIPAL_GUEST)
  {
    Auth_Store *p_auth_store = Auth_API_Get_Store();
    if (!p_auth_store)
    {
      Conversation_API_Release_Turn_Slot();
      Conversation_API_Send_Stream_Error(
          p_handle, 503, "store_unavailable", "Auth store unavailable");
      return;
    }

    int64 now_unix     = (int64)time(NULL);
    int64 window_start = conv__utc_window_start(now_unix);
    int64 resets_at    = window_start + 86400LL;

    Auth_Store_Guest_Quota_Result quota_result = Auth_Store_Guest_Reserve(
        p_auth_store,
        principal.guest_id,
        request_id,
        window_start,
        g_guest_request_output_tokens,
        g_guest_daily_turns,
        g_guest_daily_output_tokens,
        now_unix + 3600);

    if (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED ||
        quota_result == AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED)
    {
      /* Fetch current usage for 429 payload. */
      Auth_Store_Guest_Usage usage;
      memset(&usage, 0, sizeof(usage));
      Auth_Store_Guest_Get_Usage(
          p_auth_store, principal.guest_id, window_start, &usage);

      int64 turns_remaining =
          g_guest_daily_turns - usage.turns_used;
      if (turns_remaining < 0) turns_remaining = 0;
      int64 tokens_remaining =
          g_guest_daily_output_tokens
          - usage.output_tokens_used
          - usage.output_tokens_reserved;
      if (tokens_remaining < 0) tokens_remaining = 0;

      const char *code = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
          ? "guest_quota_turns_exhausted"
          : "guest_quota_tokens_exhausted";
      const char *message = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
          ? "Daily turn limit reached"
          : "Daily output token limit reached";

      char quota_body[1024];
      int qlen = snprintf(
          quota_body, sizeof(quota_body),
          "{\"error\":{\"code\":\"%s\",\"message\":\"%s\","
          "\"quota\":{\"turnsLimit\":%lld,\"turnsUsed\":%lld,"
          "\"turnsRemaining\":%lld,\"outputTokensLimit\":%lld,"
          "\"outputTokensUsed\":%lld,\"outputTokensReserved\":%lld,"
          "\"outputTokensRemaining\":%lld,\"resetsAt\":%lld}}}",
          code, message,
          (long long)g_guest_daily_turns,
          (long long)usage.turns_used,
          (long long)turns_remaining,
          (long long)g_guest_daily_output_tokens,
          (long long)usage.output_tokens_used,
          (long long)usage.output_tokens_reserved,
          (long long)tokens_remaining,
          (long long)resets_at);
      if (qlen <= 0 || (size_t)qlen >= sizeof(quota_body))
      {
        Conversation_API_Release_Turn_Slot();
        Conversation_API_Send_Stream_Error(
            p_handle, 429, code, message);
        return;
      }
      char header[512];
      Seobeo_Web_Header_Generate(header, 429,
          "application/json; charset=utf-8", qlen);
      Seobeo_Handle_Queue(
          p_handle, (const uint8 *)header, (uint32)strlen(header));
      Seobeo_Handle_Queue(
          p_handle, (const uint8 *)quota_body, (uint32)qlen);
      Seobeo_Handle_Flush(p_handle);
      Conversation_API_Release_Turn_Slot();
      return;
    }
    if (quota_result != AUTH_STORE_GUEST_QUOTA_OK)
    {
      Conversation_API_Release_Turn_Slot();
      Conversation_API_Send_Stream_Error(
          p_handle, 500, "quota_error", "Unable to check guest quota");
      return;
    }
    is_guest_reserved = TRUE;
    snprintf(quota_guest_id, sizeof(quota_guest_id), "%s",
             principal.guest_id);
  }

  Conversation_Store_Result result = Conversation_Store_Begin_Turn_Owned(
      g_conversation_store, conversation_id, &owner, request_id, prompt);
  if (result == CONVERSATION_STORE_NOT_FOUND)
  {
    if (is_guest_reserved)
      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
    Conversation_API_Release_Turn_Slot();
    Conversation_API_Send_Stream_Error(
        p_handle, 404, "not_found", "Conversation not found");
    return;
  }
  if (result == CONVERSATION_STORE_CONFLICT)
  {
    if (is_guest_reserved)
      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
    Conversation_API_Release_Turn_Slot();
    Conversation_API_Send_Stream_Error(
        p_handle, 409, "turn_in_progress", "Conversation already has an active turn");
    return;
  }
  if (result != CONVERSATION_STORE_OK)
  {
    if (is_guest_reserved)
      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
    Conversation_API_Release_Turn_Slot();
    Conversation_API_Send_Stream_Error(
        p_handle, 500, "turn_failed", "Unable to persist turn");
    return;
  }

  Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach(
      p_handle,
      "/api/conversations/turns");
  if (!p_stream || !Seobeo_SSE_Retain(p_stream))
  {
    if (is_guest_reserved)
      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
    Conversation_API_Release_Turn_Slot();
    Conversation_Store_Fail_Turn(
        g_conversation_store,
        conversation_id,
        request_id,
        "Unable to start event stream",
        FALSE);
    if (!p_stream)
      Conversation_API_Send_Stream_Error(
          p_handle, 500, "stream_failed", "Unable to start event stream");
    return;
  }

  Pending_Turn *p_turn = calloc(1, sizeof(*p_turn));
  if (!p_turn)
  {
    if (is_guest_reserved)
      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
    Conversation_API_Release_Turn_Slot();
    Conversation_Store_Fail_Turn(
        g_conversation_store,
        conversation_id,
        request_id,
        "Unable to allocate turn",
        FALSE);
    Seobeo_SSE_Send_Data(
        p_stream,
        "{\"error\":{\"code\":\"allocation_failed\"}}");
    Seobeo_SSE_Close(p_stream);
    Seobeo_SSE_Release(p_stream);
    return;
  }
  snprintf(p_turn->request_id, sizeof(p_turn->request_id), "%s", request_id);
  snprintf(
      p_turn->conversation_id,
      sizeof(p_turn->conversation_id),
      "%s",
      conversation_id);
  /* Copy owner and quota fields before arena expires (req 8) */
  p_turn->owner              = owner;
  p_turn->is_guest_reserved  = is_guest_reserved;
  p_turn->reserved_output_tokens =
      is_guest_reserved ? g_guest_request_output_tokens : 0;
  snprintf(p_turn->guest_id, sizeof(p_turn->guest_id), "%s", quota_guest_id);
  p_turn->p_stream = p_stream;

  /* Register detach callback so client disconnect triggers finalization. */
  Seobeo_SSE_Set_Detach_Callback(p_stream, Conversation_API_On_SSE_Detach, NULL);

  pthread_mutex_lock(&g_pending_mutex);
  p_turn->p_next = g_pending_turns;
  g_pending_turns = p_turn;
  pthread_mutex_unlock(&g_pending_mutex);

  if (!Inference_Bridge_Start_Turn(
          g_inference_bridge,
          request_id,
          conversation_id,
          prompt,
          prompt_profile,
          CONVERSATION_PROMPT_VERSION,
          CONVERSATION_KNOWLEDGE_VERSION,
          hist_msgs,
          hist_count))
  {
    pthread_mutex_lock(&g_pending_mutex);
    Pending_Turn *p_pending = Conversation_API_Find_Pending(request_id);
    if (p_pending)
    {
      p_pending->failed = TRUE;
      snprintf(
          p_pending->error_message,
          sizeof(p_pending->error_message),
          "Unable to dispatch inference turn");
      Conversation_API_Send_Event(
          p_pending,
          "turn.error",
          "{\"code\":\"dispatch_failed\","
          "\"message\":\"Unable to dispatch inference turn\"}");
      Conversation_API_Finalize_Pending(p_pending);
    }
    pthread_mutex_unlock(&g_pending_mutex);
  }
}

boolean Conversation_API_Init(
    const char                          *database_path,
    const Conversation_API_Guest_Policy *p_policy)
{
  if (g_conversation_store)
    return TRUE;

  if (p_policy)
  {
    /* Validate policy ranges. */
    if (p_policy->daily_turns < CONV_GUEST_DAILY_TURNS_MIN ||
        p_policy->daily_turns > CONV_GUEST_DAILY_TURNS_MAX)
    {
      Seobeo_Log(SEOBEO_ERROR,
                 "[CONV] daily_turns must be %d..%d\n",
                 CONV_GUEST_DAILY_TURNS_MIN, CONV_GUEST_DAILY_TURNS_MAX);
      return FALSE;
    }
    if (p_policy->daily_output_tokens < CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
        p_policy->daily_output_tokens > CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX)
    {
      Seobeo_Log(SEOBEO_ERROR,
                 "[CONV] daily_output_tokens must be %d..%d\n",
                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN,
                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX);
      return FALSE;
    }
    if (p_policy->request_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN ||
        p_policy->request_output_tokens > p_policy->daily_output_tokens)
    {
      Seobeo_Log(SEOBEO_ERROR,
                 "[CONV] request_output_tokens must be 1..%lld\n",
                 (long long)p_policy->daily_output_tokens);
      return FALSE;
    }
    g_guest_inference_enabled     = p_policy->guest_inference_enabled;
    g_guest_daily_turns           = p_policy->daily_turns;
    g_guest_daily_output_tokens   = p_policy->daily_output_tokens;
    g_guest_request_output_tokens = p_policy->request_output_tokens;
  }
  else
  {
    /* No explicit policy: read from environment (backwards compat). */
    const char *allow_guest = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE");
    if (!allow_guest)
    {
      const char *allow_anon = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
      if (allow_anon)
      {
        Seobeo_Log(SEOBEO_WARNING,
                   "[CONV] MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE is deprecated;"
                   " use MRJUNEJUNE_ALLOW_GUEST_INFERENCE\n");
        allow_guest = allow_anon;
      }
    }
    g_guest_inference_enabled =
        allow_guest &&
        (strcmp(allow_guest, "1") == 0 ||
         strcasecmp(allow_guest, "true") == 0);

    g_guest_request_output_tokens =
        g_guest_daily_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT
        ? g_guest_daily_output_tokens
        : CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
  }

  g_conversation_store = Conversation_Store_Create(database_path);
  return g_conversation_store != NULL;
}

boolean Conversation_API_Enable_Inference(
    const char *sidecar_path,
    const char *copilot_cli_path)
{
  if (g_inference_bridge)
    return Inference_Bridge_Is_Ready(g_inference_bridge);
  if (!sidecar_path || !copilot_cli_path)
    return FALSE;
  g_inference_bridge = Inference_Bridge_Create(
      sidecar_path,
      copilot_cli_path,
      Conversation_API_Handle_Inference_Event,
      NULL);
  if (!g_inference_bridge)
    return FALSE;
  if (!Inference_Bridge_Start(g_inference_bridge))
  {
    Inference_Bridge_Destroy(g_inference_bridge);
    g_inference_bridge = NULL;
    return FALSE;
  }
  return TRUE;
}

/* Guest-to-user transfer hook registered with Auth_API on init */
static boolean Conversation_API_Guest_Transfer_Hook(
    const char *guest_id,
    const char *user_id,
    void *context)
{
  (void)context;
  if (!g_conversation_store)
    return FALSE;

  /* Atomically transfer conversation ownership AND clear outstanding quota
   * reservations in one transaction (auth and conv tables share the same
   * SQLite file, so the write lock covers both). */
  return Conversation_Store_Transfer_Guest_To_User_Atomic(
      g_conversation_store, guest_id, user_id) == CONVERSATION_STORE_OK;
}

void Conversation_API_Register_Routes(void)
{
  /* Wire guest-to-user transfer on login */
  Auth_API_Register_Guest_Transfer_Hook(
      Conversation_API_Guest_Transfer_Hook, NULL);

  /* Wire quota callback for the session endpoint. */
  Auth_API_Register_Guest_Quota_Cb(conv_guest_quota_cb);

  Seobeo_Router_Register(
      "GET", "/api/inference/health", Conversation_API_Health);
  Seobeo_Router_Register(
      "GET", "/api/conversations", Conversation_API_List);
  Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create);
  /* Body-based claim: ID stays in request body, not in URL or logs. */
  Seobeo_Router_Register(
      "POST", "/api/conversations/claim", Conversation_API_Claim_Body);
  Seobeo_Router_Register(
      "GET", "/api/conversations/:conversation_id", Conversation_API_Get);
  Seobeo_Router_Register(
      "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update);
  Seobeo_Router_Register(
      "DELETE", "/api/conversations/:conversation_id", Conversation_API_Delete);
  Seobeo_Router_Register_Stream(
      "POST",
      "/api/conversations/:conversation_id/turns",
      Conversation_API_Turn_Stream);
}

void Conversation_API_Destroy(void)
{
  Inference_Bridge_Destroy(g_inference_bridge);
  g_inference_bridge = NULL;
  pthread_mutex_lock(&g_pending_mutex);
  while (g_pending_turns)
  {
    Pending_Turn *p_turn = g_pending_turns;
    g_pending_turns = p_turn->p_next;
    /* Release guest quota reservation on server shutdown (keep turn charge). */
    if (p_turn->is_guest_reserved)
    {
      Auth_Store *p_auth_store = Auth_API_Get_Store();
      if (p_auth_store)
        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
    }
    Conversation_Store_Fail_Turn(
        g_conversation_store,
        p_turn->conversation_id,
        p_turn->request_id,
        "Server shutdown",
        TRUE);
    Seobeo_SSE_Close(p_turn->p_stream);
    Seobeo_SSE_Release(p_turn->p_stream);
    free(p_turn->content);
    free(p_turn);
    Conversation_API_Release_Turn_Slot();
  }
  pthread_mutex_unlock(&g_pending_mutex);
  Conversation_Store_Destroy(g_conversation_store);
  g_conversation_store = NULL;
  g_guest_inference_enabled       = FALSE;
  g_guest_daily_turns             = CONV_GUEST_DAILY_TURNS_DEFAULT;
  g_guest_daily_output_tokens     = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
  g_guest_request_output_tokens   = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
}