comparison 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
comparison
equal deleted inserted replaced
259:667156fcd3e3 260:1f9877b637e9
1 #include "mrjunejune/conversation_api.h"
2
3 #include "mrjunejune/inference_bridge.h"
4 #include "mrjunejune/conversation_store.h"
5 #include "seobeo/seobeo.h"
6
7 #include <pthread.h>
8 #include <stdio.h>
9 #include <stdarg.h>
10 #include <stdlib.h>
11 #include <string.h>
12 #include <strings.h>
13 #include <time.h>
14
15 #define CONVERSATION_TITLE_MAX 200
16 #define CONVERSATION_PROMPT_MAX (32 * 1024)
17 #define CONVERSATION_RESPONSE_MAX (256 * 1024)
18 #define CONVERSATION_HISTORY_MAX (512 * 1024)
19 #define CONVERSATION_ACTIVE_MAX 4
20 #define CONVERSATION_TURNS_PER_MINUTE 60
21
22 static Conversation_Store *g_conversation_store = NULL;
23 static Inference_Bridge *g_inference_bridge = NULL;
24 static boolean g_anonymous_inference_enabled = FALSE;
25 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER;
26 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER;
27 static time_t g_admission_window = 0;
28 static uint32 g_admission_turns = 0;
29 static uint32 g_active_turns = 0;
30
31 typedef struct Pending_Turn {
32 char request_id[37];
33 char conversation_id[37];
34 Seobeo_SSE_Stream *p_stream;
35 char *content;
36 size_t content_length;
37 size_t content_capacity;
38 int64 input_tokens;
39 int64 output_tokens;
40 boolean failed;
41 boolean aborted;
42 char error_message[512];
43 struct Pending_Turn *p_next;
44 } Pending_Turn;
45
46 static Pending_Turn *g_pending_turns = NULL;
47
48 static boolean Conversation_API_Acquire_Turn_Slot(void)
49 {
50 time_t now = time(NULL);
51 pthread_mutex_lock(&g_admission_mutex);
52 if (g_admission_window == 0 || now - g_admission_window >= 60)
53 {
54 g_admission_window = now;
55 g_admission_turns = 0;
56 }
57 boolean allowed =
58 g_admission_turns < CONVERSATION_TURNS_PER_MINUTE &&
59 g_active_turns < CONVERSATION_ACTIVE_MAX;
60 if (allowed)
61 {
62 g_admission_turns++;
63 g_active_turns++;
64 }
65 pthread_mutex_unlock(&g_admission_mutex);
66 return allowed;
67 }
68
69 static void Conversation_API_Release_Turn_Slot(void)
70 {
71 pthread_mutex_lock(&g_admission_mutex);
72 if (g_active_turns > 0)
73 g_active_turns--;
74 pthread_mutex_unlock(&g_admission_mutex);
75 }
76
77 static Seobeo_Request_Entry *Conversation_API_JSON_Response(
78 Dowa_Arena *p_arena,
79 const char *status,
80 const char *body)
81 {
82 Seobeo_Request_Entry *p_response = NULL;
83 Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena);
84 Dowa_HashMap_Push_Arena(
85 p_response, "content-type", "application/json; charset=utf-8", p_arena);
86 Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena);
87 Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena);
88 return p_response;
89 }
90
91 static Seobeo_Request_Entry *Conversation_API_Error(
92 Dowa_Arena *p_arena,
93 const char *status,
94 const char *code,
95 const char *message)
96 {
97 char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
98 size_t capacity = strlen(code) + strlen(escaped) + 64;
99 char *body = Dowa_Arena_Allocate(p_arena, capacity);
100 snprintf(
101 body,
102 capacity,
103 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
104 code,
105 escaped);
106 return Conversation_API_JSON_Response(p_arena, status, body);
107 }
108
109 static const char *Conversation_API_Request_Value(
110 Seobeo_Request_Entry *p_request,
111 const char *key)
112 {
113 void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
114 return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
115 }
116
117 static boolean Conversation_API_Is_Same_Origin(
118 Seobeo_Request_Entry *p_request)
119 {
120 const char *host = Conversation_API_Request_Value(p_request, "Host");
121 const char *origin = Conversation_API_Request_Value(p_request, "Origin");
122 if (!host || !origin)
123 return FALSE;
124 const char *origin_host = strstr(origin, "://");
125 if (!origin_host)
126 return FALSE;
127 origin_host += 3;
128 const char *end = strchr(origin_host, '/');
129 size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host);
130 return strlen(host) == length && strncmp(host, origin_host, length) == 0;
131 }
132
133 static boolean Conversation_API_Is_Enabled(void)
134 {
135 return g_anonymous_inference_enabled;
136 }
137
138 static Dowa_JSON_Entry *Conversation_API_Parse_Body(
139 Seobeo_Request_Entry *p_request,
140 Dowa_Arena *p_arena)
141 {
142 const char *body = Conversation_API_Request_Value(p_request, "Body");
143 if (!body)
144 return NULL;
145 Dowa_JSON_Value value = Dowa_JSON_Parse(
146 body, (int32)strlen(body), p_arena);
147 return value.type == DOWA_JSON_OBJECT
148 ? (Dowa_JSON_Entry *)value.object_val
149 : NULL;
150 }
151
152 static void Conversation_API_Send_Stream_Error(
153 Seobeo_Handle *p_handle,
154 int status,
155 const char *code,
156 const char *message)
157 {
158 char body[1024];
159 int body_length = snprintf(
160 body,
161 sizeof(body),
162 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
163 code,
164 message);
165 if (body_length < 0 || (size_t)body_length >= sizeof(body))
166 return;
167 char header[512];
168 Seobeo_Web_Header_Generate(
169 header,
170 status,
171 "application/json; charset=utf-8",
172 body_length);
173 Seobeo_Handle_Queue(
174 p_handle, (const uint8 *)header, (uint32)strlen(header));
175 Seobeo_Handle_Queue(
176 p_handle, (const uint8 *)body, (uint32)body_length);
177 Seobeo_Handle_Flush(p_handle);
178 }
179
180 static Pending_Turn *Conversation_API_Find_Pending(
181 const char *request_id)
182 {
183 for (Pending_Turn *p_turn = g_pending_turns;
184 p_turn;
185 p_turn = p_turn->p_next)
186 {
187 if (strcmp(p_turn->request_id, request_id) == 0)
188 return p_turn;
189 }
190 return NULL;
191 }
192
193 static int32 Conversation_API_Send_Event(
194 Pending_Turn *p_turn,
195 const char *event_name,
196 const char *json)
197 {
198 Seobeo_SSE_Event event = {
199 .event = event_name,
200 .data = json,
201 .retry_ms = SEOBEO_SSE_RETRY_NONE,
202 };
203 return Seobeo_SSE_Send(p_turn->p_stream, &event);
204 }
205
206 static void Conversation_API_Remove_Pending(Pending_Turn *p_turn)
207 {
208 Pending_Turn **pp_current = &g_pending_turns;
209 while (*pp_current)
210 {
211 if (*pp_current == p_turn)
212 {
213 *pp_current = p_turn->p_next;
214 return;
215 }
216 pp_current = &(*pp_current)->p_next;
217 }
218 }
219
220 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn)
221 {
222 Conversation_Store_Result persistence;
223 if (p_turn->failed || p_turn->aborted)
224 {
225 persistence = Conversation_Store_Fail_Turn(
226 g_conversation_store,
227 p_turn->conversation_id,
228 p_turn->request_id,
229 p_turn->error_message,
230 p_turn->aborted);
231 }
232 else
233 {
234 persistence = Conversation_Store_Complete_Turn(
235 g_conversation_store,
236 p_turn->conversation_id,
237 p_turn->request_id,
238 p_turn->content ? p_turn->content : "",
239 p_turn->input_tokens,
240 p_turn->output_tokens);
241 }
242 if (persistence != CONVERSATION_STORE_OK)
243 {
244 p_turn->failed = TRUE;
245 snprintf(
246 p_turn->error_message,
247 sizeof(p_turn->error_message),
248 "Unable to persist completed turn");
249 Conversation_Store_Fail_Turn(
250 g_conversation_store,
251 p_turn->conversation_id,
252 p_turn->request_id,
253 p_turn->error_message,
254 FALSE);
255 Conversation_API_Send_Event(
256 p_turn,
257 "turn.error",
258 "{\"code\":\"storage_failed\","
259 "\"message\":\"Unable to persist completed turn\"}");
260 }
261
262 char done[256];
263 snprintf(
264 done,
265 sizeof(done),
266 "{\"request_id\":\"%s\",\"failed\":%s,\"aborted\":%s}",
267 p_turn->request_id,
268 p_turn->failed ? "true" : "false",
269 p_turn->aborted ? "true" : "false");
270 Conversation_API_Send_Event(p_turn, "turn.done", done);
271 Conversation_API_Remove_Pending(p_turn);
272 Seobeo_SSE_Close(p_turn->p_stream);
273 Seobeo_SSE_Release(p_turn->p_stream);
274 Conversation_API_Release_Turn_Slot();
275 free(p_turn->content);
276 free(p_turn);
277 }
278
279 static void Conversation_API_Handle_Inference_Event(
280 const Inference_Event *p_event,
281 void *p_user_data)
282 {
283 (void)p_user_data;
284 if (strcmp(p_event->type, "bridge.closed") == 0)
285 {
286 pthread_mutex_lock(&g_pending_mutex);
287 while (g_pending_turns)
288 {
289 Pending_Turn *p_turn = g_pending_turns;
290 p_turn->failed = TRUE;
291 snprintf(
292 p_turn->error_message,
293 sizeof(p_turn->error_message),
294 "Inference sidecar connection closed");
295 Conversation_API_Send_Event(
296 p_turn,
297 "turn.error",
298 "{\"code\":\"sidecar_closed\","
299 "\"message\":\"Inference sidecar connection closed\"}");
300 Conversation_API_Finalize_Pending(p_turn);
301 }
302 pthread_mutex_unlock(&g_pending_mutex);
303 return;
304 }
305 if (!p_event->request_id || p_event->request_id[0] == '\0')
306 return;
307
308 boolean should_abort = FALSE;
309 char abort_request_id[37] = {0};
310 char abort_conversation_id[37] = {0};
311 pthread_mutex_lock(&g_pending_mutex);
312 Pending_Turn *p_turn = Conversation_API_Find_Pending(p_event->request_id);
313 if (!p_turn)
314 {
315 pthread_mutex_unlock(&g_pending_mutex);
316 return;
317 }
318
319 size_t event_text_length =
320 strlen(p_event->delta ? p_event->delta : "") +
321 strlen(p_event->content ? p_event->content : "") +
322 strlen(p_event->error_code ? p_event->error_code : "") +
323 strlen(p_event->error_message ? p_event->error_message : "");
324 if (event_text_length > (((size_t)-1) - 8192) / 12)
325 {
326 p_turn->failed = TRUE;
327 snprintf(
328 p_turn->error_message,
329 sizeof(p_turn->error_message),
330 "Inference event exceeded memory limit");
331 Conversation_API_Send_Event(
332 p_turn,
333 "turn.error",
334 "{\"code\":\"event_too_large\","
335 "\"message\":\"Inference event exceeded memory limit\"}");
336 Conversation_API_Finalize_Pending(p_turn);
337 pthread_mutex_unlock(&g_pending_mutex);
338 return;
339 }
340 Dowa_Arena *p_arena = Dowa_Arena_Create(
341 event_text_length * 12 + 8192);
342 if (!p_arena)
343 {
344 p_turn->failed = TRUE;
345 snprintf(
346 p_turn->error_message,
347 sizeof(p_turn->error_message),
348 "Unable to allocate inference event");
349 Conversation_API_Send_Event(
350 p_turn,
351 "turn.error",
352 "{\"code\":\"allocation_failed\","
353 "\"message\":\"Unable to allocate inference event\"}");
354 Conversation_API_Finalize_Pending(p_turn);
355 pthread_mutex_unlock(&g_pending_mutex);
356 return;
357 }
358
359 if (strcmp(p_event->type, "turn.accepted") == 0)
360 {
361 char accepted[128];
362 snprintf(
363 accepted,
364 sizeof(accepted),
365 "{\"request_id\":\"%s\"}",
366 p_turn->request_id);
367 Conversation_API_Send_Event(p_turn, "turn.accepted", accepted);
368 }
369 else if (strcmp(p_event->type, "assistant.delta") == 0)
370 {
371 size_t delta_length = strlen(p_event->delta);
372 if (p_turn->content_length + delta_length > CONVERSATION_RESPONSE_MAX)
373 {
374 p_turn->failed = TRUE;
375 snprintf(
376 p_turn->error_message,
377 sizeof(p_turn->error_message),
378 "Assistant response exceeded limit");
379 should_abort = TRUE;
380 snprintf(abort_request_id, sizeof(abort_request_id), "%s",
381 p_turn->request_id);
382 snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
383 p_turn->conversation_id);
384 char error[256];
385 snprintf(
386 error,
387 sizeof(error),
388 "{\"request_id\":\"%s\",\"code\":\"response_too_large\","
389 "\"message\":\"Assistant response exceeded limit\"}",
390 p_turn->request_id);
391 Conversation_API_Send_Event(p_turn, "turn.error", error);
392 }
393 else
394 {
395 if (p_turn->content_length + delta_length + 1 >
396 p_turn->content_capacity)
397 {
398 size_t next_capacity = p_turn->content_capacity
399 ? p_turn->content_capacity * 2
400 : 4096;
401 while (next_capacity <
402 p_turn->content_length + delta_length + 1)
403 next_capacity *= 2;
404 char *next = realloc(p_turn->content, next_capacity);
405 if (!next)
406 {
407 p_turn->failed = TRUE;
408 snprintf(
409 p_turn->error_message,
410 sizeof(p_turn->error_message),
411 "Unable to buffer assistant response");
412 }
413 else
414 {
415 p_turn->content = next;
416 p_turn->content_capacity = next_capacity;
417 }
418 }
419 if (!p_turn->failed)
420 {
421 memcpy(
422 p_turn->content + p_turn->content_length,
423 p_event->delta,
424 delta_length);
425 p_turn->content_length += delta_length;
426 p_turn->content[p_turn->content_length] = '\0';
427 char *delta = Dowa_JSON_Escape_String(
428 p_event->delta, delta_length, p_arena);
429 size_t capacity = delta ? strlen(delta) + 128 : 0;
430 char *json = capacity
431 ? Dowa_Arena_Allocate(p_arena, capacity)
432 : NULL;
433 if (!delta || !json)
434 {
435 p_turn->failed = TRUE;
436 snprintf(
437 p_turn->error_message,
438 sizeof(p_turn->error_message),
439 "Unable to serialize assistant delta");
440 Conversation_API_Send_Event(
441 p_turn,
442 "turn.error",
443 "{\"code\":\"serialize_failed\","
444 "\"message\":\"Unable to serialize assistant delta\"}");
445 should_abort = TRUE;
446 snprintf(abort_request_id, sizeof(abort_request_id), "%s",
447 p_turn->request_id);
448 snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
449 p_turn->conversation_id);
450 }
451 else
452 {
453 snprintf(
454 json,
455 capacity,
456 "{\"request_id\":\"%s\",\"delta\":\"%s\"}",
457 p_turn->request_id,
458 delta);
459 if (Conversation_API_Send_Event(
460 p_turn, "assistant.delta", json) < 0)
461 {
462 p_turn->aborted = TRUE;
463 should_abort = TRUE;
464 snprintf(abort_request_id, sizeof(abort_request_id), "%s",
465 p_turn->request_id);
466 snprintf(abort_conversation_id, sizeof(abort_conversation_id), "%s",
467 p_turn->conversation_id);
468 }
469 }
470 }
471 }
472 }
473 else if (strcmp(p_event->type, "assistant.completed") == 0)
474 {
475 size_t content_length = strlen(p_event->content);
476 if (content_length > CONVERSATION_RESPONSE_MAX)
477 {
478 p_turn->failed = TRUE;
479 snprintf(
480 p_turn->error_message,
481 sizeof(p_turn->error_message),
482 "Assistant response exceeded limit");
483 char error[256];
484 snprintf(
485 error,
486 sizeof(error),
487 "{\"request_id\":\"%s\",\"code\":\"response_too_large\","
488 "\"message\":\"Assistant response exceeded limit\"}",
489 p_turn->request_id);
490 Conversation_API_Send_Event(p_turn, "turn.error", error);
491 }
492 else
493 {
494 char *content = realloc(p_turn->content, content_length + 1);
495 if (content)
496 {
497 p_turn->content = content;
498 p_turn->content_capacity = content_length + 1;
499 memcpy(p_turn->content, p_event->content, content_length + 1);
500 p_turn->content_length = content_length;
501 }
502 else
503 {
504 p_turn->failed = TRUE;
505 snprintf(
506 p_turn->error_message,
507 sizeof(p_turn->error_message),
508 "Unable to buffer assistant response");
509 }
510 if (!p_turn->failed)
511 {
512 char *escaped = Dowa_JSON_Escape_String(
513 p_event->content, content_length, p_arena);
514 size_t capacity = escaped ? strlen(escaped) + 128 : 0;
515 char *json = capacity
516 ? Dowa_Arena_Allocate(p_arena, capacity)
517 : NULL;
518 if (!escaped || !json)
519 {
520 p_turn->failed = TRUE;
521 snprintf(
522 p_turn->error_message,
523 sizeof(p_turn->error_message),
524 "Unable to serialize assistant response");
525 Conversation_API_Send_Event(
526 p_turn,
527 "turn.error",
528 "{\"code\":\"serialize_failed\","
529 "\"message\":\"Unable to serialize assistant response\"}");
530 }
531 else
532 {
533 snprintf(
534 json,
535 capacity,
536 "{\"request_id\":\"%s\",\"content\":\"%s\"}",
537 p_turn->request_id,
538 escaped);
539 Conversation_API_Send_Event(p_turn, "assistant.completed", json);
540 }
541 }
542 }
543 }
544 else if (strcmp(p_event->type, "assistant.usage") == 0)
545 {
546 p_turn->input_tokens = p_event->input_tokens;
547 p_turn->output_tokens = p_event->output_tokens;
548 char usage[256];
549 snprintf(
550 usage,
551 sizeof(usage),
552 "{\"request_id\":\"%s\",\"input_tokens\":%lld,"
553 "\"output_tokens\":%lld}",
554 p_turn->request_id,
555 (long long)p_turn->input_tokens,
556 (long long)p_turn->output_tokens);
557 Conversation_API_Send_Event(p_turn, "assistant.usage", usage);
558 }
559 else if (strcmp(p_event->type, "turn.error") == 0)
560 {
561 p_turn->failed = TRUE;
562 snprintf(
563 p_turn->error_message,
564 sizeof(p_turn->error_message),
565 "%s",
566 p_event->error_message);
567 char *code = Dowa_JSON_Escape_String(
568 p_event->error_code, 0, p_arena);
569 char *message = Dowa_JSON_Escape_String(
570 p_event->error_message, 0, p_arena);
571 size_t capacity = code && message
572 ? strlen(code) + strlen(message) + 160
573 : 0;
574 char *json = capacity
575 ? Dowa_Arena_Allocate(p_arena, capacity)
576 : NULL;
577 if (!code || !message || !json)
578 {
579 Conversation_API_Send_Event(
580 p_turn,
581 "turn.error",
582 "{\"code\":\"serialize_failed\","
583 "\"message\":\"Unable to serialize inference error\"}");
584 }
585 else
586 {
587 snprintf(
588 json,
589 capacity,
590 "{\"request_id\":\"%s\",\"code\":\"%s\",\"message\":\"%s\"}",
591 p_turn->request_id,
592 code,
593 message);
594 Conversation_API_Send_Event(p_turn, "turn.error", json);
595 }
596 }
597 else if (strcmp(p_event->type, "turn.done") == 0)
598 {
599 p_turn->failed = p_turn->failed || p_event->failed;
600 p_turn->aborted = p_turn->aborted || p_event->aborted;
601 Conversation_API_Finalize_Pending(p_turn);
602 }
603
604 Dowa_Arena_Free(p_arena);
605 pthread_mutex_unlock(&g_pending_mutex);
606 if (should_abort)
607 Inference_Bridge_Abort_Turn(
608 g_inference_bridge, abort_request_id, abort_conversation_id);
609 }
610
611 static Seobeo_Request_Entry *Conversation_API_Create(
612 Seobeo_Request_Entry *p_request,
613 Dowa_Arena *p_arena)
614 {
615 if (!Conversation_API_Is_Enabled())
616 return Conversation_API_Error(
617 p_arena, "503", "inference_disabled", "Inference API is disabled");
618 if (!Conversation_API_Is_Same_Origin(p_request))
619 return Conversation_API_Error(
620 p_arena, "403", "origin_rejected", "Same-origin request required");
621 if (!g_conversation_store)
622 return Conversation_API_Error(
623 p_arena, "503", "store_unavailable", "Conversation store unavailable");
624
625 const char *title = "";
626 const char *body = Conversation_API_Request_Value(p_request, "Body");
627 if (body && body[0] != '\0')
628 {
629 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
630 if (!object)
631 return Conversation_API_Error(
632 p_arena, "400", "invalid_json", "Request body must be a JSON object");
633 char *parsed_title = Dowa_JSON_Get_String(object, "title");
634 if (parsed_title)
635 title = parsed_title;
636 }
637 if (strlen(title) > CONVERSATION_TITLE_MAX)
638 return Conversation_API_Error(
639 p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
640
641 char conversation_id[37];
642 if (Conversation_Store_Create_Conversation(
643 g_conversation_store,
644 title,
645 conversation_id) != CONVERSATION_STORE_OK)
646 return Conversation_API_Error(
647 p_arena, "500", "create_failed", "Unable to create conversation");
648
649 char *response_body = Dowa_Arena_Allocate(p_arena, 64);
650 snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
651 return Conversation_API_JSON_Response(p_arena, "201", response_body);
652 }
653
654 static boolean Conversation_API_Append(
655 char *output,
656 size_t capacity,
657 size_t *p_offset,
658 const char *format,
659 ...)
660 {
661 if (*p_offset >= capacity)
662 return FALSE;
663 va_list args;
664 va_start(args, format);
665 int written = vsnprintf(
666 output + *p_offset, capacity - *p_offset, format, args);
667 va_end(args);
668 if (written < 0 || (size_t)written >= capacity - *p_offset)
669 return FALSE;
670 *p_offset += (size_t)written;
671 return TRUE;
672 }
673
674 static Seobeo_Request_Entry *Conversation_API_Get(
675 Seobeo_Request_Entry *p_request,
676 Dowa_Arena *p_arena)
677 {
678 if (!Conversation_API_Is_Enabled())
679 return Conversation_API_Error(
680 p_arena, "503", "inference_disabled", "Inference API is disabled");
681 const char *conversation_id =
682 Conversation_API_Request_Value(p_request, ":conversation_id");
683 if (!conversation_id)
684 return Conversation_API_Error(
685 p_arena, "400", "missing_id", "Conversation ID is required");
686
687 Conversation_Record record;
688 Conversation_Store_Result result = Conversation_Store_Get(
689 g_conversation_store, conversation_id, &record, p_arena);
690 if (result == CONVERSATION_STORE_NOT_FOUND)
691 return Conversation_API_Error(
692 p_arena, "404", "not_found", "Conversation not found");
693 if (result != CONVERSATION_STORE_OK)
694 return Conversation_API_Error(
695 p_arena, "500", "load_failed", "Unable to load conversation");
696
697 if (!record.id || !record.title || !record.status)
698 return Conversation_API_Error(
699 p_arena, "500", "load_failed", "Conversation data exceeded limits");
700 size_t history_size = strlen(record.title) + strlen(record.status);
701 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
702 {
703 Conversation_Turn *p_turn = &record.turns[i];
704 if (!p_turn->role || !p_turn->content || !p_turn->status ||
705 !p_turn->request_id || !p_turn->error_message)
706 return Conversation_API_Error(
707 p_arena, "500", "load_failed", "Conversation data exceeded limits");
708 history_size +=
709 strlen(p_turn->role) + strlen(p_turn->content) +
710 strlen(p_turn->status) + strlen(p_turn->request_id) +
711 strlen(p_turn->error_message);
712 if (history_size > CONVERSATION_HISTORY_MAX)
713 return Conversation_API_Error(
714 p_arena,
715 "413",
716 "history_too_large",
717 "Conversation history exceeds response limit");
718 }
719 char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena);
720 if (!escaped_title)
721 return Conversation_API_Error(
722 p_arena, "500", "serialize_failed", "Unable to serialize conversation");
723 size_t capacity = strlen(escaped_title) + 512;
724 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
725 {
726 Conversation_Turn *p_turn = &record.turns[i];
727 capacity +=
728 (strlen(p_turn->role) + strlen(p_turn->content) +
729 strlen(p_turn->status) + strlen(p_turn->request_id) +
730 strlen(p_turn->error_message)) * 6 + 320;
731 }
732 char *body = Dowa_Arena_Allocate(p_arena, capacity);
733 if (!body)
734 return Conversation_API_Error(
735 p_arena, "500", "serialize_failed", "Response exceeds memory limit");
736 size_t offset = 0;
737 if (!Conversation_API_Append(
738 body,
739 capacity,
740 &offset,
741 "{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
742 "\"created_at\":%lld,\"updated_at\":%lld,\"turns\":[",
743 record.id,
744 escaped_title,
745 record.status,
746 (long long)record.created_at,
747 (long long)record.updated_at))
748 return Conversation_API_Error(
749 p_arena, "500", "serialize_failed", "Response too large");
750
751 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
752 {
753 Conversation_Turn *p_turn = &record.turns[i];
754 char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena);
755 char *content = Dowa_JSON_Escape_String(p_turn->content, 0, p_arena);
756 char *status = Dowa_JSON_Escape_String(p_turn->status, 0, p_arena);
757 char *request_id = Dowa_JSON_Escape_String(
758 p_turn->request_id, 0, p_arena);
759 char *error = Dowa_JSON_Escape_String(
760 p_turn->error_message, 0, p_arena);
761 if (!Conversation_API_Append(
762 body,
763 capacity,
764 &offset,
765 "%s{\"id\":%lld,\"sequence\":%lld,\"role\":\"%s\","
766 "\"content\":\"%s\",\"status\":\"%s\","
767 "\"request_id\":\"%s\",\"error\":\"%s\","
768 "\"input_tokens\":%lld,\"output_tokens\":%lld,"
769 "\"created_at\":%lld,\"completed_at\":%lld}",
770 i == 0 ? "" : ",",
771 (long long)p_turn->id,
772 (long long)p_turn->sequence,
773 role,
774 content,
775 status,
776 request_id,
777 error,
778 (long long)p_turn->input_tokens,
779 (long long)p_turn->output_tokens,
780 (long long)p_turn->created_at,
781 (long long)p_turn->completed_at))
782 return Conversation_API_Error(
783 p_arena, "500", "serialize_failed", "Response too large");
784 }
785 if (!Conversation_API_Append(body, capacity, &offset, "]}"))
786 return Conversation_API_Error(
787 p_arena, "500", "serialize_failed", "Response too large");
788 return Conversation_API_JSON_Response(p_arena, "200", body);
789 }
790
791 static Seobeo_Request_Entry *Conversation_API_Update(
792 Seobeo_Request_Entry *p_request,
793 Dowa_Arena *p_arena)
794 {
795 if (!Conversation_API_Is_Enabled())
796 return Conversation_API_Error(
797 p_arena, "503", "inference_disabled", "Inference API is disabled");
798 if (!Conversation_API_Is_Same_Origin(p_request))
799 return Conversation_API_Error(
800 p_arena, "403", "origin_rejected", "Same-origin request required");
801 const char *conversation_id =
802 Conversation_API_Request_Value(p_request, ":conversation_id");
803 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
804 char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL;
805 if (!conversation_id || !title)
806 return Conversation_API_Error(
807 p_arena, "400", "invalid_request", "Conversation ID and title required");
808 if (strlen(title) > CONVERSATION_TITLE_MAX)
809 return Conversation_API_Error(
810 p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
811
812 Conversation_Store_Result result = Conversation_Store_Update_Title(
813 g_conversation_store, conversation_id, title);
814 if (result == CONVERSATION_STORE_NOT_FOUND)
815 return Conversation_API_Error(
816 p_arena, "404", "not_found", "Conversation not found");
817 if (result != CONVERSATION_STORE_OK)
818 return Conversation_API_Error(
819 p_arena, "500", "update_failed", "Unable to update conversation");
820 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
821 }
822
823 static Seobeo_Request_Entry *Conversation_API_Delete(
824 Seobeo_Request_Entry *p_request,
825 Dowa_Arena *p_arena)
826 {
827 if (!Conversation_API_Is_Enabled())
828 return Conversation_API_Error(
829 p_arena, "503", "inference_disabled", "Inference API is disabled");
830 if (!Conversation_API_Is_Same_Origin(p_request))
831 return Conversation_API_Error(
832 p_arena, "403", "origin_rejected", "Same-origin request required");
833 const char *conversation_id =
834 Conversation_API_Request_Value(p_request, ":conversation_id");
835 if (!conversation_id)
836 return Conversation_API_Error(
837 p_arena, "400", "missing_id", "Conversation ID is required");
838 Conversation_Store_Result result = Conversation_Store_Delete(
839 g_conversation_store, conversation_id);
840 if (result == CONVERSATION_STORE_NOT_FOUND)
841 return Conversation_API_Error(
842 p_arena, "404", "not_found", "Conversation not found");
843 if (result != CONVERSATION_STORE_OK)
844 return Conversation_API_Error(
845 p_arena, "500", "delete_failed", "Unable to delete conversation");
846 if (Inference_Bridge_Is_Ready(g_inference_bridge))
847 {
848 char request_id[37];
849 if (Conversation_Store_Generate_UUID(request_id))
850 Inference_Bridge_Delete_Conversation(
851 g_inference_bridge, request_id, conversation_id);
852 }
853
854 Seobeo_Request_Entry *p_response = NULL;
855 Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena);
856 Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena);
857 return p_response;
858 }
859
860 static Seobeo_Request_Entry *Conversation_API_Health(
861 Seobeo_Request_Entry *p_request,
862 Dowa_Arena *p_arena)
863 {
864 (void)p_request;
865 boolean ready =
866 Conversation_API_Is_Enabled() &&
867 g_conversation_store &&
868 Inference_Bridge_Is_Ready(g_inference_bridge);
869 return Conversation_API_JSON_Response(
870 p_arena,
871 ready ? "200" : "503",
872 ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}");
873 }
874
875 static void Conversation_API_Turn_Stream(
876 Seobeo_Handle *p_handle,
877 Seobeo_Request_Entry *p_request,
878 Dowa_Arena *p_arena)
879 {
880 if (!Conversation_API_Is_Enabled())
881 {
882 Conversation_API_Send_Stream_Error(
883 p_handle, 503, "inference_disabled", "Inference API is disabled");
884 return;
885 }
886 if (!Conversation_API_Is_Same_Origin(p_request))
887 {
888 Conversation_API_Send_Stream_Error(
889 p_handle, 403, "origin_rejected", "Same-origin request required");
890 return;
891 }
892 if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge))
893 {
894 Conversation_API_Send_Stream_Error(
895 p_handle, 503, "inference_unavailable", "Inference runtime unavailable");
896 return;
897 }
898 const char *conversation_id =
899 Conversation_API_Request_Value(p_request, ":conversation_id");
900 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
901 char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL;
902 if (!conversation_id || !prompt || prompt[0] == '\0')
903 {
904 Conversation_API_Send_Stream_Error(
905 p_handle, 400, "invalid_request", "Conversation ID and prompt required");
906 return;
907 }
908 size_t prompt_length = strlen(prompt);
909 if (prompt_length > CONVERSATION_PROMPT_MAX)
910 {
911 Conversation_API_Send_Stream_Error(
912 p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB");
913 return;
914 }
915 if (!Conversation_API_Acquire_Turn_Slot())
916 {
917 Conversation_API_Send_Stream_Error(
918 p_handle, 429, "rate_limited", "Inference capacity exhausted");
919 return;
920 }
921
922 char request_id[37];
923 if (!Conversation_Store_Generate_UUID(request_id))
924 {
925 Conversation_API_Release_Turn_Slot();
926 Conversation_API_Send_Stream_Error(
927 p_handle, 500, "id_failed", "Unable to create request ID");
928 return;
929 }
930 Conversation_Store_Result result = Conversation_Store_Begin_Turn(
931 g_conversation_store,
932 conversation_id,
933 request_id,
934 prompt);
935 if (result == CONVERSATION_STORE_NOT_FOUND)
936 {
937 Conversation_API_Release_Turn_Slot();
938 Conversation_API_Send_Stream_Error(
939 p_handle, 404, "not_found", "Conversation not found");
940 return;
941 }
942 if (result == CONVERSATION_STORE_CONFLICT)
943 {
944 Conversation_API_Release_Turn_Slot();
945 Conversation_API_Send_Stream_Error(
946 p_handle, 409, "turn_in_progress", "Conversation already has an active turn");
947 return;
948 }
949 if (result != CONVERSATION_STORE_OK)
950 {
951 Conversation_API_Release_Turn_Slot();
952 Conversation_API_Send_Stream_Error(
953 p_handle, 500, "turn_failed", "Unable to persist turn");
954 return;
955 }
956
957 Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach(
958 p_handle,
959 "/api/conversations/turns");
960 if (!p_stream || !Seobeo_SSE_Retain(p_stream))
961 {
962 Conversation_API_Release_Turn_Slot();
963 Conversation_Store_Fail_Turn(
964 g_conversation_store,
965 conversation_id,
966 request_id,
967 "Unable to start event stream",
968 FALSE);
969 if (!p_stream)
970 Conversation_API_Send_Stream_Error(
971 p_handle, 500, "stream_failed", "Unable to start event stream");
972 return;
973 }
974
975 Pending_Turn *p_turn = calloc(1, sizeof(*p_turn));
976 if (!p_turn)
977 {
978 Conversation_API_Release_Turn_Slot();
979 Conversation_Store_Fail_Turn(
980 g_conversation_store,
981 conversation_id,
982 request_id,
983 "Unable to allocate turn",
984 FALSE);
985 Seobeo_SSE_Send_Data(
986 p_stream,
987 "{\"error\":{\"code\":\"allocation_failed\"}}");
988 Seobeo_SSE_Close(p_stream);
989 Seobeo_SSE_Release(p_stream);
990 return;
991 }
992 snprintf(p_turn->request_id, sizeof(p_turn->request_id), "%s", request_id);
993 snprintf(
994 p_turn->conversation_id,
995 sizeof(p_turn->conversation_id),
996 "%s",
997 conversation_id);
998 p_turn->p_stream = p_stream;
999
1000 pthread_mutex_lock(&g_pending_mutex);
1001 p_turn->p_next = g_pending_turns;
1002 g_pending_turns = p_turn;
1003 pthread_mutex_unlock(&g_pending_mutex);
1004
1005 if (!Inference_Bridge_Start_Turn(
1006 g_inference_bridge, request_id, conversation_id, prompt))
1007 {
1008 pthread_mutex_lock(&g_pending_mutex);
1009 Pending_Turn *p_pending = Conversation_API_Find_Pending(request_id);
1010 if (p_pending)
1011 {
1012 p_pending->failed = TRUE;
1013 snprintf(
1014 p_pending->error_message,
1015 sizeof(p_pending->error_message),
1016 "Unable to dispatch inference turn");
1017 Conversation_API_Send_Event(
1018 p_pending,
1019 "turn.error",
1020 "{\"code\":\"dispatch_failed\","
1021 "\"message\":\"Unable to dispatch inference turn\"}");
1022 Conversation_API_Finalize_Pending(p_pending);
1023 }
1024 pthread_mutex_unlock(&g_pending_mutex);
1025 }
1026 }
1027
1028 boolean Conversation_API_Init(const char *database_path)
1029 {
1030 if (g_conversation_store)
1031 return TRUE;
1032 const char *allow_anonymous = getenv(
1033 "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
1034 g_anonymous_inference_enabled =
1035 allow_anonymous &&
1036 (strcmp(allow_anonymous, "1") == 0 ||
1037 strcasecmp(allow_anonymous, "true") == 0);
1038 g_conversation_store = Conversation_Store_Create(database_path);
1039 return g_conversation_store != NULL;
1040 }
1041
1042 boolean Conversation_API_Enable_Inference(
1043 const char *sidecar_path,
1044 const char *copilot_cli_path)
1045 {
1046 if (g_inference_bridge)
1047 return Inference_Bridge_Is_Ready(g_inference_bridge);
1048 if (!sidecar_path || !copilot_cli_path)
1049 return FALSE;
1050 g_inference_bridge = Inference_Bridge_Create(
1051 sidecar_path,
1052 copilot_cli_path,
1053 Conversation_API_Handle_Inference_Event,
1054 NULL);
1055 if (!g_inference_bridge)
1056 return FALSE;
1057 if (!Inference_Bridge_Start(g_inference_bridge))
1058 {
1059 Inference_Bridge_Destroy(g_inference_bridge);
1060 g_inference_bridge = NULL;
1061 return FALSE;
1062 }
1063 return TRUE;
1064 }
1065
1066 void Conversation_API_Register_Routes(void)
1067 {
1068 Seobeo_Router_Register(
1069 "GET", "/api/inference/health", Conversation_API_Health);
1070 Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create);
1071 Seobeo_Router_Register(
1072 "GET", "/api/conversations/:conversation_id", Conversation_API_Get);
1073 Seobeo_Router_Register(
1074 "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update);
1075 Seobeo_Router_Register(
1076 "DELETE", "/api/conversations/:conversation_id", Conversation_API_Delete);
1077 Seobeo_Router_Register_Stream(
1078 "POST",
1079 "/api/conversations/:conversation_id/turns",
1080 Conversation_API_Turn_Stream);
1081 }
1082
1083 void Conversation_API_Destroy(void)
1084 {
1085 Inference_Bridge_Destroy(g_inference_bridge);
1086 g_inference_bridge = NULL;
1087 pthread_mutex_lock(&g_pending_mutex);
1088 while (g_pending_turns)
1089 {
1090 Pending_Turn *p_turn = g_pending_turns;
1091 g_pending_turns = p_turn->p_next;
1092 Conversation_Store_Fail_Turn(
1093 g_conversation_store,
1094 p_turn->conversation_id,
1095 p_turn->request_id,
1096 "Server shutdown",
1097 TRUE);
1098 Seobeo_SSE_Close(p_turn->p_stream);
1099 Seobeo_SSE_Release(p_turn->p_stream);
1100 free(p_turn->content);
1101 free(p_turn);
1102 Conversation_API_Release_Turn_Slot();
1103 }
1104 pthread_mutex_unlock(&g_pending_mutex);
1105 Conversation_Store_Destroy(g_conversation_store);
1106 g_conversation_store = NULL;
1107 g_anonymous_inference_enabled = FALSE;
1108 }