Mercurial
diff mrjunejune/conversation_api.c @ 260:1f9877b637e9
Add Copilot-powered cyberpunk JRPG chat
Integrate the production JRPG chat with Seobeo streaming, Deita persistence, and a Bazel-managed Copilot SDK and LiteLLM inference stack.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 09:19:41 -0700 |
| parents | |
| children | b401627fc49e |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/conversation_api.c Wed Aug 05 09:19:41 2026 -0700 @@ -0,0 +1,1108 @@ +#include "mrjunejune/conversation_api.h" + +#include "mrjunejune/inference_bridge.h" +#include "mrjunejune/conversation_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_ACTIVE_MAX 4 +#define CONVERSATION_TURNS_PER_MINUTE 60 + +static Conversation_Store *g_conversation_store = NULL; +static Inference_Bridge *g_inference_bridge = NULL; +static boolean g_anonymous_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; + +typedef struct Pending_Turn { + char request_id[37]; + char conversation_id[37]; + 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; + char error_message[512]; + struct Pending_Turn *p_next; +} Pending_Turn; + +static Pending_Turn *g_pending_turns = NULL; + +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 Seobeo_Request_Entry *Conversation_API_JSON_Response( + Dowa_Arena *p_arena, + const char *status, + const char *body) +{ + 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); + return p_response; +} + +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 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_Same_Origin( + Seobeo_Request_Entry *p_request) +{ + const char *host = Conversation_API_Request_Value(p_request, "Host"); + const char *origin = Conversation_API_Request_Value(p_request, "Origin"); + if (!host || !origin) + return FALSE; + const char *origin_host = strstr(origin, "://"); + if (!origin_host) + return FALSE; + origin_host += 3; + const char *end = strchr(origin_host, '/'); + size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host); + return strlen(host) == length && strncmp(host, origin_host, length) == 0; +} + +static boolean Conversation_API_Is_Enabled(void) +{ + return g_anonymous_inference_enabled; +} + +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 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) +{ + 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; + } + + 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; + 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); +} + +static Seobeo_Request_Entry *Conversation_API_Create( + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + if (!Conversation_API_Is_Enabled()) + return Conversation_API_Error( + p_arena, "503", "inference_disabled", "Inference API is disabled"); + if (!Conversation_API_Is_Same_Origin(p_request)) + return Conversation_API_Error( + p_arena, "403", "origin_rejected", "Same-origin request required"); + if (!g_conversation_store) + return Conversation_API_Error( + p_arena, "503", "store_unavailable", "Conversation store unavailable"); + + 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_Conversation( + g_conversation_store, + title, + 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); +} + +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; +} + +static Seobeo_Request_Entry *Conversation_API_Get( + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + if (!Conversation_API_Is_Enabled()) + return Conversation_API_Error( + p_arena, "503", "inference_disabled", "Inference API is disabled"); + 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_Record record; + Conversation_Store_Result result = Conversation_Store_Get( + g_conversation_store, conversation_id, &record, p_arena); + 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", "load_failed", "Unable to load conversation"); + + if (!record.id || !record.title || !record.status) + return Conversation_API_Error( + p_arena, "500", "load_failed", "Conversation data exceeded limits"); + 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( + p_arena, "500", "load_failed", "Conversation data exceeded limits"); + 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( + p_arena, + "413", + "history_too_large", + "Conversation history exceeds response limit"); + } + char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena); + if (!escaped_title) + return Conversation_API_Error( + p_arena, "500", "serialize_failed", "Unable to serialize conversation"); + 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( + p_arena, "500", "serialize_failed", "Response exceeds memory limit"); + 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( + p_arena, "500", "serialize_failed", "Response too large"); + + 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( + p_arena, "500", "serialize_failed", "Response too large"); + } + if (!Conversation_API_Append(body, capacity, &offset, "]}")) + return Conversation_API_Error( + p_arena, "500", "serialize_failed", "Response too large"); + return Conversation_API_JSON_Response(p_arena, "200", body); +} + +static Seobeo_Request_Entry *Conversation_API_Update( + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + if (!Conversation_API_Is_Enabled()) + return Conversation_API_Error( + p_arena, "503", "inference_disabled", "Inference API is disabled"); + if (!Conversation_API_Is_Same_Origin(p_request)) + return Conversation_API_Error( + p_arena, "403", "origin_rejected", "Same-origin request required"); + 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( + g_conversation_store, conversation_id, 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}"); +} + +static Seobeo_Request_Entry *Conversation_API_Delete( + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + if (!Conversation_API_Is_Enabled()) + return Conversation_API_Error( + p_arena, "503", "inference_disabled", "Inference API is disabled"); + if (!Conversation_API_Is_Same_Origin(p_request)) + return Conversation_API_Error( + p_arena, "403", "origin_rejected", "Same-origin request required"); + 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( + g_conversation_store, conversation_id); + 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; +} + +static Seobeo_Request_Entry *Conversation_API_Health( + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + (void)p_request; + boolean ready = + Conversation_API_Is_Enabled() && + 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\"}"); +} + +static void Conversation_API_Turn_Stream( + Seobeo_Handle *p_handle, + Seobeo_Request_Entry *p_request, + Dowa_Arena *p_arena) +{ + if (!Conversation_API_Is_Enabled()) + { + Conversation_API_Send_Stream_Error( + p_handle, 503, "inference_disabled", "Inference API is disabled"); + return; + } + if (!Conversation_API_Is_Same_Origin(p_request)) + { + Conversation_API_Send_Stream_Error( + p_handle, 403, "origin_rejected", "Same-origin request required"); + 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; + } + 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()) + { + Conversation_API_Send_Stream_Error( + p_handle, 429, "rate_limited", "Inference capacity exhausted"); + 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; + } + Conversation_Store_Result result = Conversation_Store_Begin_Turn( + g_conversation_store, + conversation_id, + request_id, + prompt); + if (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 (result == CONVERSATION_STORE_CONFLICT) + { + 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) + { + 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)) + { + 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) + { + 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); + p_turn->p_stream = p_stream; + + 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)) + { + 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) +{ + if (g_conversation_store) + return TRUE; + const char *allow_anonymous = getenv( + "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE"); + g_anonymous_inference_enabled = + allow_anonymous && + (strcmp(allow_anonymous, "1") == 0 || + strcasecmp(allow_anonymous, "true") == 0); + 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; +} + +void Conversation_API_Register_Routes(void) +{ + Seobeo_Router_Register( + "GET", "/api/inference/health", Conversation_API_Health); + Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create); + 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; + 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_anonymous_inference_enabled = FALSE; +}