Mercurial
view mrjunejune/conversation_api.c @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -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; }