Mercurial
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 } |