Mercurial
view mrjunejune/conversation_api.c @ 265:056790c4fb0d
add role-aware Epi assistant prompts
Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 10:50:30 -0700 |
| parents | 04fee26ecce0 |
| 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; }