Mercurial
comparison mrjunejune/conversation_api.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | b401627fc49e |
| children | 056790c4fb0d |
comparison
equal
deleted
inserted
replaced
| 263:ee04e4e69fed | 264:04fee26ecce0 |
|---|---|
| 1 #include "mrjunejune/conversation_api.h" | 1 #include "mrjunejune/conversation_api.h" |
| 2 | 2 |
| 3 #include "mrjunejune/auth_api.h" | |
| 3 #include "mrjunejune/inference_bridge.h" | 4 #include "mrjunejune/inference_bridge.h" |
| 4 #include "mrjunejune/conversation_store.h" | 5 #include "mrjunejune/conversation_store.h" |
| 6 #include "auth/auth_store.h" | |
| 5 #include "seobeo/seobeo.h" | 7 #include "seobeo/seobeo.h" |
| 6 | 8 |
| 7 #include <pthread.h> | 9 #include <pthread.h> |
| 8 #include <stdio.h> | 10 #include <stdio.h> |
| 9 #include <stdarg.h> | 11 #include <stdarg.h> |
| 18 #define CONVERSATION_HISTORY_MAX (512 * 1024) | 20 #define CONVERSATION_HISTORY_MAX (512 * 1024) |
| 19 #define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024) | 21 #define CONVERSATION_CUSTOM_EVENT_MAX (3 * 1024) |
| 20 #define CONVERSATION_EVENT_NAME_MAX 64 | 22 #define CONVERSATION_EVENT_NAME_MAX 64 |
| 21 #define CONVERSATION_ACTIVE_MAX 4 | 23 #define CONVERSATION_ACTIVE_MAX 4 |
| 22 #define CONVERSATION_TURNS_PER_MINUTE 60 | 24 #define CONVERSATION_TURNS_PER_MINUTE 60 |
| 25 /* Buffer large enough for a guest Set-Cookie directive */ | |
| 26 #define CONV_GUEST_COOKIE_CAPACITY (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256) | |
| 27 | |
| 28 /* Quota policy — read from env at init, validated at startup. */ | |
| 29 #define CONV_GUEST_DAILY_TURNS_DEFAULT 10 | |
| 30 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT 20000 | |
| 31 #define CONV_GUEST_DAILY_TURNS_MIN 1 | |
| 32 #define CONV_GUEST_DAILY_TURNS_MAX 10000 | |
| 33 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN 1 | |
| 34 #define CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX 1000000 | |
| 35 #define CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN 1 | |
| 36 /* Default per-request reservation (tokens); capped to daily at init. */ | |
| 37 #define CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048 | |
| 23 | 38 |
| 24 static Conversation_Store *g_conversation_store = NULL; | 39 static Conversation_Store *g_conversation_store = NULL; |
| 25 static Inference_Bridge *g_inference_bridge = NULL; | 40 static Inference_Bridge *g_inference_bridge = NULL; |
| 26 static boolean g_anonymous_inference_enabled = FALSE; | 41 static boolean g_guest_inference_enabled = FALSE; |
| 27 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER; | 42 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER; |
| 28 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER; | 43 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER; |
| 29 static time_t g_admission_window = 0; | 44 static time_t g_admission_window = 0; |
| 30 static uint32 g_admission_turns = 0; | 45 static uint32 g_admission_turns = 0; |
| 31 static uint32 g_active_turns = 0; | 46 static uint32 g_active_turns = 0; |
| 32 | 47 |
| 48 /* Quota policy (validated at init). */ | |
| 49 static int64 g_guest_daily_turns = CONV_GUEST_DAILY_TURNS_DEFAULT; | |
| 50 static int64 g_guest_daily_output_tokens = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT; | |
| 51 static int64 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT; | |
| 52 | |
| 33 typedef struct Pending_Turn { | 53 typedef struct Pending_Turn { |
| 34 char request_id[37]; | 54 char request_id[37]; |
| 35 char conversation_id[37]; | 55 char conversation_id[37]; |
| 56 /* owner identity copied before request arena expires (req 8) */ | |
| 57 Conversation_Owner owner; | |
| 36 Seobeo_SSE_Stream *p_stream; | 58 Seobeo_SSE_Stream *p_stream; |
| 37 char *content; | 59 char *content; |
| 38 size_t content_length; | 60 size_t content_length; |
| 39 size_t content_capacity; | 61 size_t content_capacity; |
| 40 int64 input_tokens; | 62 int64 input_tokens; |
| 41 int64 output_tokens; | 63 int64 output_tokens; |
| 42 boolean failed; | 64 boolean failed; |
| 43 boolean aborted; | 65 boolean aborted; |
| 66 boolean has_usage_event; /* TRUE once assistant.usage received */ | |
| 67 boolean finalized; /* TRUE once Finalize_Pending has run (once-only) */ | |
| 44 char error_message[512]; | 68 char error_message[512]; |
| 69 /* Guest quota fields — copied before arena expires. */ | |
| 70 boolean is_guest_reserved; /* TRUE if quota was reserved for this turn */ | |
| 71 int64 reserved_output_tokens; | |
| 72 char guest_id[37]; /* guest_id from principal (safe copy) */ | |
| 45 struct Pending_Turn *p_next; | 73 struct Pending_Turn *p_next; |
| 46 } Pending_Turn; | 74 } Pending_Turn; |
| 47 | 75 |
| 48 static Pending_Turn *g_pending_turns = NULL; | 76 static Pending_Turn *g_pending_turns = NULL; |
| 49 | 77 |
| 108 if (g_active_turns > 0) | 136 if (g_active_turns > 0) |
| 109 g_active_turns--; | 137 g_active_turns--; |
| 110 pthread_mutex_unlock(&g_admission_mutex); | 138 pthread_mutex_unlock(&g_admission_mutex); |
| 111 } | 139 } |
| 112 | 140 |
| 113 static Seobeo_Request_Entry *Conversation_API_JSON_Response( | 141 static const char *Conversation_API_Request_Value( |
| 142 Seobeo_Request_Entry *p_request, | |
| 143 const char *key) | |
| 144 { | |
| 145 void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key); | |
| 146 return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL; | |
| 147 } | |
| 148 | |
| 149 static boolean Conversation_API_Is_Inference_Ready(void) | |
| 150 { | |
| 151 return g_guest_inference_enabled; | |
| 152 } | |
| 153 | |
| 154 /* UTC midnight for a Unix timestamp. */ | |
| 155 static int64 conv__utc_window_start(int64 unix_ts) | |
| 156 { | |
| 157 return (unix_ts / 86400LL) * 86400LL; | |
| 158 } | |
| 159 | |
| 160 /* Build guest quota JSON into a fixed buffer (null-terminated). */ | |
| 161 static boolean conv_guest_quota_cb( | |
| 162 const char *guest_id, | |
| 163 int64 current_unix, | |
| 164 char *json_out, | |
| 165 size_t json_capacity) | |
| 166 { | |
| 167 Auth_Store *p_store = Auth_API_Get_Store(); | |
| 168 if (!p_store || !guest_id || !json_out || json_capacity == 0) | |
| 169 { | |
| 170 if (json_out && json_capacity > 0) | |
| 171 strncpy(json_out, "null", json_capacity); | |
| 172 return TRUE; | |
| 173 } | |
| 174 | |
| 175 int64 window_start = conv__utc_window_start(current_unix); | |
| 176 Auth_Store_Guest_Usage usage; | |
| 177 memset(&usage, 0, sizeof(usage)); | |
| 178 Auth_Store_Guest_Get_Usage(p_store, guest_id, window_start, &usage); | |
| 179 | |
| 180 int64 turns_remaining = | |
| 181 g_guest_daily_turns - usage.turns_used; | |
| 182 if (turns_remaining < 0) turns_remaining = 0; | |
| 183 int64 tokens_remaining = | |
| 184 g_guest_daily_output_tokens - usage.output_tokens_used - usage.output_tokens_reserved; | |
| 185 if (tokens_remaining < 0) tokens_remaining = 0; | |
| 186 int64 resets_at = window_start + 86400LL; | |
| 187 | |
| 188 snprintf( | |
| 189 json_out, json_capacity, | |
| 190 "{\"turnsLimit\":%lld,\"turnsUsed\":%lld,\"turnsRemaining\":%lld," | |
| 191 "\"outputTokensLimit\":%lld,\"outputTokensUsed\":%lld," | |
| 192 "\"outputTokensReserved\":%lld,\"outputTokensRemaining\":%lld," | |
| 193 "\"resetsAt\":%lld}", | |
| 194 (long long)g_guest_daily_turns, | |
| 195 (long long)usage.turns_used, | |
| 196 (long long)turns_remaining, | |
| 197 (long long)g_guest_daily_output_tokens, | |
| 198 (long long)usage.output_tokens_used, | |
| 199 (long long)usage.output_tokens_reserved, | |
| 200 (long long)tokens_remaining, | |
| 201 (long long)resets_at); | |
| 202 return TRUE; | |
| 203 } | |
| 204 | |
| 205 static Dowa_JSON_Entry *Conversation_API_Parse_Body( | |
| 206 Seobeo_Request_Entry *p_request, | |
| 207 Dowa_Arena *p_arena) | |
| 208 { | |
| 209 const char *body = Conversation_API_Request_Value(p_request, "Body"); | |
| 210 if (!body) | |
| 211 return NULL; | |
| 212 Dowa_JSON_Value value = Dowa_JSON_Parse( | |
| 213 body, (int32)strlen(body), p_arena); | |
| 214 return value.type == DOWA_JSON_OBJECT | |
| 215 ? (Dowa_JSON_Entry *)value.object_val | |
| 216 : NULL; | |
| 217 } | |
| 218 | |
| 219 static const char *Conversation_API_Query_Param( | |
| 220 Seobeo_Request_Entry *p_request, | |
| 221 const char *param_name, | |
| 222 char *output, | |
| 223 size_t output_capacity) | |
| 224 { | |
| 225 const char *qs = Conversation_API_Request_Value(p_request, "QueryString"); | |
| 226 if (!qs || !param_name || !output || output_capacity == 0) | |
| 227 return NULL; | |
| 228 output[0] = '\0'; | |
| 229 size_t name_len = strlen(param_name); | |
| 230 const char *p = qs; | |
| 231 while (*p) | |
| 232 { | |
| 233 if (strncmp(p, param_name, name_len) == 0 && p[name_len] == '=') | |
| 234 { | |
| 235 p += name_len + 1; | |
| 236 size_t i = 0; | |
| 237 while (*p && *p != '&' && i < output_capacity - 1) | |
| 238 { | |
| 239 if (p[0] == '%' && p[1] && p[2]) | |
| 240 { | |
| 241 char hex[3] = {p[1], p[2], '\0'}; | |
| 242 output[i++] = (char)(int)strtol(hex, NULL, 16); | |
| 243 p += 3; | |
| 244 } | |
| 245 else if (*p == '+') | |
| 246 { | |
| 247 output[i++] = ' '; | |
| 248 p++; | |
| 249 } | |
| 250 else | |
| 251 { | |
| 252 output[i++] = *p++; | |
| 253 } | |
| 254 } | |
| 255 output[i] = '\0'; | |
| 256 return output[0] != '\0' ? output : NULL; | |
| 257 } | |
| 258 while (*p && *p != '&') p++; | |
| 259 if (*p == '&') p++; | |
| 260 } | |
| 261 return NULL; | |
| 262 } | |
| 263 | |
| 264 /* | |
| 265 * Build a JSON response, optionally setting a guest cookie when one was | |
| 266 * freshly issued by Auth_API_Resolve_Principal. | |
| 267 */ | |
| 268 static Seobeo_Request_Entry *Conversation_API_JSON_Response_With_Cookie( | |
| 114 Dowa_Arena *p_arena, | 269 Dowa_Arena *p_arena, |
| 115 const char *status, | 270 const char *status, |
| 116 const char *body) | 271 const char *body, |
| 272 const char *new_guest_cookie) | |
| 117 { | 273 { |
| 118 Seobeo_Request_Entry *p_response = NULL; | 274 Seobeo_Request_Entry *p_response = NULL; |
| 119 Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena); | 275 Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena); |
| 120 Dowa_HashMap_Push_Arena( | 276 Dowa_HashMap_Push_Arena( |
| 121 p_response, "content-type", "application/json; charset=utf-8", p_arena); | 277 p_response, "content-type", "application/json; charset=utf-8", p_arena); |
| 122 Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena); | 278 Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena); |
| 123 Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena); | 279 Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena); |
| 280 if (new_guest_cookie && new_guest_cookie[0] != '\0') | |
| 281 Dowa_HashMap_Push_Arena( | |
| 282 p_response, "Set-Cookie", (char *)new_guest_cookie, p_arena); | |
| 124 return p_response; | 283 return p_response; |
| 284 } | |
| 285 | |
| 286 static Seobeo_Request_Entry *Conversation_API_JSON_Response( | |
| 287 Dowa_Arena *p_arena, | |
| 288 const char *status, | |
| 289 const char *body) | |
| 290 { | |
| 291 return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, NULL); | |
| 125 } | 292 } |
| 126 | 293 |
| 127 static Seobeo_Request_Entry *Conversation_API_Error( | 294 static Seobeo_Request_Entry *Conversation_API_Error( |
| 128 Dowa_Arena *p_arena, | 295 Dowa_Arena *p_arena, |
| 129 const char *status, | 296 const char *status, |
| 140 code, | 307 code, |
| 141 escaped); | 308 escaped); |
| 142 return Conversation_API_JSON_Response(p_arena, status, body); | 309 return Conversation_API_JSON_Response(p_arena, status, body); |
| 143 } | 310 } |
| 144 | 311 |
| 145 static const char *Conversation_API_Request_Value( | 312 static Seobeo_Request_Entry *Conversation_API_Error_With_Cookie( |
| 313 Dowa_Arena *p_arena, | |
| 314 const char *status, | |
| 315 const char *code, | |
| 316 const char *message, | |
| 317 const char *new_guest_cookie) | |
| 318 { | |
| 319 char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena); | |
| 320 size_t capacity = strlen(code) + strlen(escaped) + 64; | |
| 321 char *body = Dowa_Arena_Allocate(p_arena, capacity); | |
| 322 snprintf( | |
| 323 body, | |
| 324 capacity, | |
| 325 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", | |
| 326 code, | |
| 327 escaped); | |
| 328 return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, new_guest_cookie); | |
| 329 } | |
| 330 | |
| 331 /* | |
| 332 * Resolve the principal for this request, optionally setting a new guest | |
| 333 * cookie on the response when returned. | |
| 334 * Returns FALSE only on internal error (treat as 500). | |
| 335 */ | |
| 336 static boolean Conversation_API_Resolve( | |
| 146 Seobeo_Request_Entry *p_request, | 337 Seobeo_Request_Entry *p_request, |
| 147 const char *key) | 338 Auth_Principal *p_principal, |
| 148 { | 339 char *new_guest_cookie, |
| 149 void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key); | 340 Dowa_Arena *p_arena) |
| 150 return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL; | 341 { |
| 151 } | 342 return Auth_API_Resolve_Principal( |
| 152 | 343 p_request, p_principal, p_arena, |
| 153 static boolean Conversation_API_Is_Same_Origin( | 344 new_guest_cookie, CONV_GUEST_COOKIE_CAPACITY); |
| 154 Seobeo_Request_Entry *p_request) | 345 } |
| 155 { | 346 |
| 156 const char *host = Conversation_API_Request_Value(p_request, "Host"); | 347 /* |
| 157 const char *origin = Conversation_API_Request_Value(p_request, "Origin"); | 348 * Resolve an existing principal for mutation requests. |
| 158 if (!host || !origin) | 349 * Does NOT create a new guest identity. |
| 159 return FALSE; | 350 * Returns FALSE on internal error (500); sets *p_found=FALSE when no |
| 160 const char *origin_host = strstr(origin, "://"); | 351 * existing session/guest is present (caller must return 401). |
| 161 if (!origin_host) | 352 */ |
| 162 return FALSE; | 353 static boolean Conversation_API_Resolve_Existing( |
| 163 origin_host += 3; | |
| 164 const char *end = strchr(origin_host, '/'); | |
| 165 size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host); | |
| 166 return strlen(host) == length && strncmp(host, origin_host, length) == 0; | |
| 167 } | |
| 168 | |
| 169 static boolean Conversation_API_Is_Enabled(void) | |
| 170 { | |
| 171 return g_anonymous_inference_enabled; | |
| 172 } | |
| 173 | |
| 174 static Dowa_JSON_Entry *Conversation_API_Parse_Body( | |
| 175 Seobeo_Request_Entry *p_request, | 354 Seobeo_Request_Entry *p_request, |
| 176 Dowa_Arena *p_arena) | 355 Auth_Principal *p_principal, |
| 177 { | 356 Dowa_Arena *p_arena, |
| 178 const char *body = Conversation_API_Request_Value(p_request, "Body"); | 357 boolean *p_found) |
| 179 if (!body) | 358 { |
| 180 return NULL; | 359 return Auth_API_Resolve_Existing_Principal( |
| 181 Dowa_JSON_Value value = Dowa_JSON_Parse( | 360 p_request, p_principal, p_arena, p_found); |
| 182 body, (int32)strlen(body), p_arena); | 361 } |
| 183 return value.type == DOWA_JSON_OBJECT | 362 |
| 184 ? (Dowa_JSON_Entry *)value.object_val | 363 /* |
| 185 : NULL; | 364 * Map a resolved principal to a Conversation_Owner. |
| 365 * Always succeeds for USER and GUEST principals. | |
| 366 */ | |
| 367 static void Conversation_API_Owner_From_Principal( | |
| 368 const Auth_Principal *p_principal, | |
| 369 Conversation_Owner *p_owner) | |
| 370 { | |
| 371 if (p_principal->kind == AUTH_PRINCIPAL_USER) | |
| 372 { | |
| 373 p_owner->kind = CONVERSATION_OWNER_KIND_USER; | |
| 374 strncpy(p_owner->id, p_principal->user_id, sizeof(p_owner->id) - 1); | |
| 375 p_owner->id[sizeof(p_owner->id) - 1] = '\0'; | |
| 376 } | |
| 377 else | |
| 378 { | |
| 379 p_owner->kind = CONVERSATION_OWNER_KIND_GUEST; | |
| 380 strncpy(p_owner->id, p_principal->guest_id, sizeof(p_owner->id) - 1); | |
| 381 p_owner->id[sizeof(p_owner->id) - 1] = '\0'; | |
| 382 } | |
| 186 } | 383 } |
| 187 | 384 |
| 188 static void Conversation_API_Send_Stream_Error( | 385 static void Conversation_API_Send_Stream_Error( |
| 189 Seobeo_Handle *p_handle, | 386 Seobeo_Handle *p_handle, |
| 190 int status, | 387 int status, |
| 253 } | 450 } |
| 254 } | 451 } |
| 255 | 452 |
| 256 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn) | 453 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn) |
| 257 { | 454 { |
| 455 /* Idempotence guard — called with g_pending_mutex held. */ | |
| 456 if (p_turn->finalized) | |
| 457 return; | |
| 458 p_turn->finalized = TRUE; | |
| 459 | |
| 460 /* Reconcile or release guest quota reservation before persistence. */ | |
| 461 if (p_turn->is_guest_reserved) | |
| 462 { | |
| 463 Auth_Store *p_auth_store = Auth_API_Get_Store(); | |
| 464 if (p_auth_store) | |
| 465 { | |
| 466 if (!p_turn->failed && !p_turn->aborted) | |
| 467 { | |
| 468 int64 actual = p_turn->has_usage_event | |
| 469 ? p_turn->output_tokens | |
| 470 : p_turn->reserved_output_tokens; | |
| 471 Auth_Store_Guest_Reconcile( | |
| 472 p_auth_store, p_turn->request_id, actual); | |
| 473 } | |
| 474 else | |
| 475 Auth_Store_Guest_Release(p_auth_store, p_turn->request_id); | |
| 476 } | |
| 477 } | |
| 478 | |
| 258 Conversation_Store_Result persistence; | 479 Conversation_Store_Result persistence; |
| 259 if (p_turn->failed || p_turn->aborted) | 480 if (p_turn->failed || p_turn->aborted) |
| 260 { | 481 { |
| 261 persistence = Conversation_Store_Fail_Turn( | 482 persistence = Conversation_Store_Fail_Turn( |
| 262 g_conversation_store, | 483 g_conversation_store, |
| 621 } | 842 } |
| 622 else if (strcmp(p_event->type, "assistant.usage") == 0) | 843 else if (strcmp(p_event->type, "assistant.usage") == 0) |
| 623 { | 844 { |
| 624 p_turn->input_tokens = p_event->input_tokens; | 845 p_turn->input_tokens = p_event->input_tokens; |
| 625 p_turn->output_tokens = p_event->output_tokens; | 846 p_turn->output_tokens = p_event->output_tokens; |
| 847 p_turn->has_usage_event = TRUE; | |
| 626 char usage[256]; | 848 char usage[256]; |
| 627 snprintf( | 849 snprintf( |
| 628 usage, | 850 usage, |
| 629 sizeof(usage), | 851 sizeof(usage), |
| 630 "{\"request_id\":\"%s\",\"input_tokens\":%lld," | 852 "{\"request_id\":\"%s\",\"input_tokens\":%lld," |
| 684 if (should_abort) | 906 if (should_abort) |
| 685 Inference_Bridge_Abort_Turn( | 907 Inference_Bridge_Abort_Turn( |
| 686 g_inference_bridge, abort_request_id, abort_conversation_id); | 908 g_inference_bridge, abort_request_id, abort_conversation_id); |
| 687 } | 909 } |
| 688 | 910 |
| 689 static Seobeo_Request_Entry *Conversation_API_Create( | 911 /* ------------------------------------------------------------------ */ |
| 690 Seobeo_Request_Entry *p_request, | 912 /* HTTP handler helpers */ |
| 691 Dowa_Arena *p_arena) | 913 /* ------------------------------------------------------------------ */ |
| 692 { | |
| 693 if (!Conversation_API_Is_Enabled()) | |
| 694 return Conversation_API_Error( | |
| 695 p_arena, "503", "inference_disabled", "Inference API is disabled"); | |
| 696 if (!Conversation_API_Is_Same_Origin(p_request)) | |
| 697 return Conversation_API_Error( | |
| 698 p_arena, "403", "origin_rejected", "Same-origin request required"); | |
| 699 if (!g_conversation_store) | |
| 700 return Conversation_API_Error( | |
| 701 p_arena, "503", "store_unavailable", "Conversation store unavailable"); | |
| 702 | |
| 703 const char *title = ""; | |
| 704 const char *body = Conversation_API_Request_Value(p_request, "Body"); | |
| 705 if (body && body[0] != '\0') | |
| 706 { | |
| 707 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); | |
| 708 if (!object) | |
| 709 return Conversation_API_Error( | |
| 710 p_arena, "400", "invalid_json", "Request body must be a JSON object"); | |
| 711 char *parsed_title = Dowa_JSON_Get_String(object, "title"); | |
| 712 if (parsed_title) | |
| 713 title = parsed_title; | |
| 714 } | |
| 715 if (strlen(title) > CONVERSATION_TITLE_MAX) | |
| 716 return Conversation_API_Error( | |
| 717 p_arena, "400", "invalid_title", "Title exceeds 200 bytes"); | |
| 718 | |
| 719 char conversation_id[37]; | |
| 720 if (Conversation_Store_Create_Conversation( | |
| 721 g_conversation_store, | |
| 722 title, | |
| 723 conversation_id) != CONVERSATION_STORE_OK) | |
| 724 return Conversation_API_Error( | |
| 725 p_arena, "500", "create_failed", "Unable to create conversation"); | |
| 726 | |
| 727 char *response_body = Dowa_Arena_Allocate(p_arena, 64); | |
| 728 snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id); | |
| 729 return Conversation_API_JSON_Response(p_arena, "201", response_body); | |
| 730 } | |
| 731 | 914 |
| 732 static boolean Conversation_API_Append( | 915 static boolean Conversation_API_Append( |
| 733 char *output, | 916 char *output, |
| 734 size_t capacity, | 917 size_t capacity, |
| 735 size_t *p_offset, | 918 size_t *p_offset, |
| 747 return FALSE; | 930 return FALSE; |
| 748 *p_offset += (size_t)written; | 931 *p_offset += (size_t)written; |
| 749 return TRUE; | 932 return TRUE; |
| 750 } | 933 } |
| 751 | 934 |
| 935 /* ------------------------------------------------------------------ */ | |
| 936 /* GET /api/conversations?cursor=<ts>_<id>&limit=20 */ | |
| 937 /* ------------------------------------------------------------------ */ | |
| 938 | |
| 939 static Seobeo_Request_Entry *Conversation_API_List( | |
| 940 Seobeo_Request_Entry *p_request, | |
| 941 Dowa_Arena *p_arena) | |
| 942 { | |
| 943 if (!g_conversation_store) | |
| 944 return Conversation_API_Error( | |
| 945 p_arena, "503", "store_unavailable", "Conversation store unavailable"); | |
| 946 | |
| 947 Auth_Principal principal; | |
| 948 char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0}; | |
| 949 if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena)) | |
| 950 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 951 if (principal.must_change_password) | |
| 952 return Conversation_API_Error_With_Cookie( | |
| 953 p_arena, "403", "password_change_required", | |
| 954 "Password change required", new_guest_cookie); | |
| 955 | |
| 956 Conversation_Owner owner; | |
| 957 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 958 | |
| 959 /* Parse cursor and limit from query string */ | |
| 960 char cursor_buf[80] = {0}; | |
| 961 char limit_buf[8] = {0}; | |
| 962 Conversation_API_Query_Param(p_request, "cursor", cursor_buf, sizeof(cursor_buf)); | |
| 963 Conversation_API_Query_Param(p_request, "limit", limit_buf, sizeof(limit_buf)); | |
| 964 | |
| 965 int64 cursor_updated_at = 0; | |
| 966 char cursor_id[37] = {0}; | |
| 967 if (cursor_buf[0] != '\0') | |
| 968 { | |
| 969 /* cursor format: <updated_at>_<uuid> */ | |
| 970 const char *underscore = strchr(cursor_buf, '_'); | |
| 971 if (!underscore || underscore == cursor_buf || | |
| 972 strlen(underscore + 1) != 36) | |
| 973 return Conversation_API_Error_With_Cookie( | |
| 974 p_arena, "400", "invalid_cursor", "Cursor format invalid", | |
| 975 new_guest_cookie); | |
| 976 char ts_part[32] = {0}; | |
| 977 size_t ts_len = (size_t)(underscore - cursor_buf); | |
| 978 if (ts_len >= sizeof(ts_part)) | |
| 979 return Conversation_API_Error_With_Cookie( | |
| 980 p_arena, "400", "invalid_cursor", "Cursor format invalid", | |
| 981 new_guest_cookie); | |
| 982 memcpy(ts_part, cursor_buf, ts_len); | |
| 983 cursor_updated_at = (int64)atoll(ts_part); | |
| 984 if (cursor_updated_at <= 0) | |
| 985 return Conversation_API_Error_With_Cookie( | |
| 986 p_arena, "400", "invalid_cursor", "Cursor timestamp invalid", | |
| 987 new_guest_cookie); | |
| 988 strncpy(cursor_id, underscore + 1, 36); | |
| 989 cursor_id[36] = '\0'; | |
| 990 } | |
| 991 | |
| 992 int32 limit = 20; | |
| 993 if (limit_buf[0] != '\0') | |
| 994 { | |
| 995 int parsed = atoi(limit_buf); | |
| 996 if (parsed >= 1 && parsed <= 50) | |
| 997 limit = parsed; | |
| 998 else if (parsed > 50) | |
| 999 limit = 50; | |
| 1000 } | |
| 1001 | |
| 1002 Conversation_Summary *summaries = NULL; | |
| 1003 int32 count = 0; | |
| 1004 Conversation_Store_Result result = Conversation_Store_List( | |
| 1005 g_conversation_store, &owner, | |
| 1006 cursor_updated_at, cursor_id[0] ? cursor_id : NULL, | |
| 1007 limit, &summaries, &count, p_arena); | |
| 1008 if (result != CONVERSATION_STORE_OK) | |
| 1009 return Conversation_API_Error_With_Cookie( | |
| 1010 p_arena, "500", "list_failed", "Unable to list conversations", | |
| 1011 new_guest_cookie); | |
| 1012 | |
| 1013 /* Estimate response size */ | |
| 1014 size_t capacity = 128; | |
| 1015 for (int32 i = 0; i < count; i++) | |
| 1016 { | |
| 1017 Conversation_Summary *s = &summaries[i]; | |
| 1018 capacity += (s->title ? strlen(s->title) : 0) * 6 + | |
| 1019 (s->last_message_preview ? strlen(s->last_message_preview) : 0) * 6 + | |
| 1020 256; | |
| 1021 } | |
| 1022 char *body = Dowa_Arena_Allocate(p_arena, capacity); | |
| 1023 if (!body) | |
| 1024 return Conversation_API_Error_With_Cookie( | |
| 1025 p_arena, "500", "serialize_failed", "Response exceeds memory limit", | |
| 1026 new_guest_cookie); | |
| 1027 | |
| 1028 size_t offset = 0; | |
| 1029 if (!Conversation_API_Append(body, capacity, &offset, | |
| 1030 "{\"conversations\":[")) | |
| 1031 return Conversation_API_Error_With_Cookie( | |
| 1032 p_arena, "500", "serialize_failed", "Response too large", | |
| 1033 new_guest_cookie); | |
| 1034 | |
| 1035 for (int32 i = 0; i < count; i++) | |
| 1036 { | |
| 1037 Conversation_Summary *s = &summaries[i]; | |
| 1038 char *esc_title = Dowa_JSON_Escape_String( | |
| 1039 s->title ? s->title : "", 0, p_arena); | |
| 1040 char *esc_preview = Dowa_JSON_Escape_String( | |
| 1041 s->last_message_preview ? s->last_message_preview : "", 0, p_arena); | |
| 1042 if (!Conversation_API_Append( | |
| 1043 body, capacity, &offset, | |
| 1044 "%s{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\"," | |
| 1045 "\"created_at\":%lld,\"updated_at\":%lld," | |
| 1046 "\"turn_count\":%lld,\"last_message_preview\":\"%s\"}", | |
| 1047 i == 0 ? "" : ",", | |
| 1048 s->id ? s->id : "", | |
| 1049 esc_title ? esc_title : "", | |
| 1050 s->status ? s->status : "", | |
| 1051 (long long)s->created_at, | |
| 1052 (long long)s->updated_at, | |
| 1053 (long long)s->turn_count, | |
| 1054 esc_preview ? esc_preview : "")) | |
| 1055 return Conversation_API_Error_With_Cookie( | |
| 1056 p_arena, "500", "serialize_failed", "Response too large", | |
| 1057 new_guest_cookie); | |
| 1058 } | |
| 1059 | |
| 1060 /* Next cursor: last item's (updated_at, id) */ | |
| 1061 char cursor_out[80] = "null"; | |
| 1062 if (count == limit && count > 0) | |
| 1063 { | |
| 1064 Conversation_Summary *last = &summaries[count - 1]; | |
| 1065 if (last->id) | |
| 1066 snprintf(cursor_out, sizeof(cursor_out), "\"%lld_%s\"", | |
| 1067 (long long)last->updated_at, last->id); | |
| 1068 } | |
| 1069 if (!Conversation_API_Append(body, capacity, &offset, | |
| 1070 "],\"cursor\":%s}", cursor_out)) | |
| 1071 return Conversation_API_Error_With_Cookie( | |
| 1072 p_arena, "500", "serialize_failed", "Response too large", | |
| 1073 new_guest_cookie); | |
| 1074 | |
| 1075 return Conversation_API_JSON_Response_With_Cookie( | |
| 1076 p_arena, "200", body, new_guest_cookie); | |
| 1077 } | |
| 1078 | |
| 1079 /* ------------------------------------------------------------------ */ | |
| 1080 /* POST /api/conversations */ | |
| 1081 /* ------------------------------------------------------------------ */ | |
| 1082 | |
| 1083 static Seobeo_Request_Entry *Conversation_API_Create( | |
| 1084 Seobeo_Request_Entry *p_request, | |
| 1085 Dowa_Arena *p_arena) | |
| 1086 { | |
| 1087 if (!g_conversation_store) | |
| 1088 return Conversation_API_Error( | |
| 1089 p_arena, "503", "store_unavailable", "Conversation store unavailable"); | |
| 1090 | |
| 1091 Auth_Principal principal; | |
| 1092 boolean found = FALSE; | |
| 1093 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found)) | |
| 1094 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 1095 if (!found) | |
| 1096 return Conversation_API_Error( | |
| 1097 p_arena, "401", "auth_required", "Bootstrap session first"); | |
| 1098 if (principal.must_change_password) | |
| 1099 return Conversation_API_Error( | |
| 1100 p_arena, "403", "password_change_required", | |
| 1101 "Password change required"); | |
| 1102 if (!Auth_API_Verify_CSRF(p_request, &principal)) | |
| 1103 return Conversation_API_Error( | |
| 1104 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required"); | |
| 1105 | |
| 1106 Conversation_Owner owner; | |
| 1107 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 1108 | |
| 1109 const char *title = ""; | |
| 1110 const char *body = Conversation_API_Request_Value(p_request, "Body"); | |
| 1111 if (body && body[0] != '\0') | |
| 1112 { | |
| 1113 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); | |
| 1114 if (!object) | |
| 1115 return Conversation_API_Error( | |
| 1116 p_arena, "400", "invalid_json", "Request body must be a JSON object"); | |
| 1117 char *parsed_title = Dowa_JSON_Get_String(object, "title"); | |
| 1118 if (parsed_title) | |
| 1119 title = parsed_title; | |
| 1120 } | |
| 1121 if (strlen(title) > CONVERSATION_TITLE_MAX) | |
| 1122 return Conversation_API_Error( | |
| 1123 p_arena, "400", "invalid_title", "Title exceeds 200 bytes"); | |
| 1124 | |
| 1125 char conversation_id[37]; | |
| 1126 if (Conversation_Store_Create_Owned( | |
| 1127 g_conversation_store, title, &owner, | |
| 1128 conversation_id) != CONVERSATION_STORE_OK) | |
| 1129 return Conversation_API_Error( | |
| 1130 p_arena, "500", "create_failed", "Unable to create conversation"); | |
| 1131 | |
| 1132 char *response_body = Dowa_Arena_Allocate(p_arena, 64); | |
| 1133 snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id); | |
| 1134 return Conversation_API_JSON_Response(p_arena, "201", response_body); | |
| 1135 } | |
| 1136 | |
| 1137 /* ------------------------------------------------------------------ */ | |
| 1138 /* GET /api/conversations/:conversation_id */ | |
| 1139 /* ------------------------------------------------------------------ */ | |
| 1140 | |
| 752 static Seobeo_Request_Entry *Conversation_API_Get( | 1141 static Seobeo_Request_Entry *Conversation_API_Get( |
| 753 Seobeo_Request_Entry *p_request, | 1142 Seobeo_Request_Entry *p_request, |
| 754 Dowa_Arena *p_arena) | 1143 Dowa_Arena *p_arena) |
| 755 { | 1144 { |
| 756 if (!Conversation_API_Is_Enabled()) | 1145 if (!g_conversation_store) |
| 757 return Conversation_API_Error( | 1146 return Conversation_API_Error( |
| 758 p_arena, "503", "inference_disabled", "Inference API is disabled"); | 1147 p_arena, "503", "store_unavailable", "Conversation store unavailable"); |
| 1148 | |
| 1149 Auth_Principal principal; | |
| 1150 char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0}; | |
| 1151 if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena)) | |
| 1152 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 1153 if (principal.must_change_password) | |
| 1154 return Conversation_API_Error_With_Cookie( | |
| 1155 p_arena, "403", "password_change_required", | |
| 1156 "Password change required", new_guest_cookie); | |
| 1157 | |
| 1158 Conversation_Owner owner; | |
| 1159 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 1160 | |
| 759 const char *conversation_id = | 1161 const char *conversation_id = |
| 760 Conversation_API_Request_Value(p_request, ":conversation_id"); | 1162 Conversation_API_Request_Value(p_request, ":conversation_id"); |
| 761 if (!conversation_id) | 1163 if (!conversation_id) |
| 762 return Conversation_API_Error( | 1164 return Conversation_API_Error_With_Cookie( |
| 763 p_arena, "400", "missing_id", "Conversation ID is required"); | 1165 p_arena, "400", "missing_id", "Conversation ID is required", |
| 1166 new_guest_cookie); | |
| 764 | 1167 |
| 765 Conversation_Record record; | 1168 Conversation_Record record; |
| 766 Conversation_Store_Result result = Conversation_Store_Get( | 1169 Conversation_Store_Result result = Conversation_Store_Get_Owned( |
| 767 g_conversation_store, conversation_id, &record, p_arena); | 1170 g_conversation_store, conversation_id, &owner, &record, p_arena); |
| 768 if (result == CONVERSATION_STORE_NOT_FOUND) | 1171 if (result == CONVERSATION_STORE_NOT_FOUND) |
| 769 return Conversation_API_Error( | 1172 return Conversation_API_Error_With_Cookie( |
| 770 p_arena, "404", "not_found", "Conversation not found"); | 1173 p_arena, "404", "not_found", "Conversation not found", |
| 1174 new_guest_cookie); | |
| 771 if (result != CONVERSATION_STORE_OK) | 1175 if (result != CONVERSATION_STORE_OK) |
| 772 return Conversation_API_Error( | 1176 return Conversation_API_Error_With_Cookie( |
| 773 p_arena, "500", "load_failed", "Unable to load conversation"); | 1177 p_arena, "500", "load_failed", "Unable to load conversation", |
| 1178 new_guest_cookie); | |
| 774 | 1179 |
| 775 if (!record.id || !record.title || !record.status) | 1180 if (!record.id || !record.title || !record.status) |
| 776 return Conversation_API_Error( | 1181 return Conversation_API_Error_With_Cookie( |
| 777 p_arena, "500", "load_failed", "Conversation data exceeded limits"); | 1182 p_arena, "500", "load_failed", "Conversation data exceeded limits", |
| 1183 new_guest_cookie); | |
| 778 size_t history_size = strlen(record.title) + strlen(record.status); | 1184 size_t history_size = strlen(record.title) + strlen(record.status); |
| 779 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) | 1185 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) |
| 780 { | 1186 { |
| 781 Conversation_Turn *p_turn = &record.turns[i]; | 1187 Conversation_Turn *p_turn = &record.turns[i]; |
| 782 if (!p_turn->role || !p_turn->content || !p_turn->status || | 1188 if (!p_turn->role || !p_turn->content || !p_turn->status || |
| 783 !p_turn->request_id || !p_turn->error_message) | 1189 !p_turn->request_id || !p_turn->error_message) |
| 784 return Conversation_API_Error( | 1190 return Conversation_API_Error_With_Cookie( |
| 785 p_arena, "500", "load_failed", "Conversation data exceeded limits"); | 1191 p_arena, "500", "load_failed", "Conversation data exceeded limits", |
| 1192 new_guest_cookie); | |
| 786 history_size += | 1193 history_size += |
| 787 strlen(p_turn->role) + strlen(p_turn->content) + | 1194 strlen(p_turn->role) + strlen(p_turn->content) + |
| 788 strlen(p_turn->status) + strlen(p_turn->request_id) + | 1195 strlen(p_turn->status) + strlen(p_turn->request_id) + |
| 789 strlen(p_turn->error_message); | 1196 strlen(p_turn->error_message); |
| 790 if (history_size > CONVERSATION_HISTORY_MAX) | 1197 if (history_size > CONVERSATION_HISTORY_MAX) |
| 791 return Conversation_API_Error( | 1198 return Conversation_API_Error_With_Cookie( |
| 792 p_arena, | 1199 p_arena, |
| 793 "413", | 1200 "413", |
| 794 "history_too_large", | 1201 "history_too_large", |
| 795 "Conversation history exceeds response limit"); | 1202 "Conversation history exceeds response limit", |
| 1203 new_guest_cookie); | |
| 796 } | 1204 } |
| 797 char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena); | 1205 char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena); |
| 798 if (!escaped_title) | 1206 if (!escaped_title) |
| 799 return Conversation_API_Error( | 1207 return Conversation_API_Error_With_Cookie( |
| 800 p_arena, "500", "serialize_failed", "Unable to serialize conversation"); | 1208 p_arena, "500", "serialize_failed", "Unable to serialize conversation", |
| 1209 new_guest_cookie); | |
| 801 size_t capacity = strlen(escaped_title) + 512; | 1210 size_t capacity = strlen(escaped_title) + 512; |
| 802 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) | 1211 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) |
| 803 { | 1212 { |
| 804 Conversation_Turn *p_turn = &record.turns[i]; | 1213 Conversation_Turn *p_turn = &record.turns[i]; |
| 805 capacity += | 1214 capacity += |
| 807 strlen(p_turn->status) + strlen(p_turn->request_id) + | 1216 strlen(p_turn->status) + strlen(p_turn->request_id) + |
| 808 strlen(p_turn->error_message)) * 6 + 320; | 1217 strlen(p_turn->error_message)) * 6 + 320; |
| 809 } | 1218 } |
| 810 char *body = Dowa_Arena_Allocate(p_arena, capacity); | 1219 char *body = Dowa_Arena_Allocate(p_arena, capacity); |
| 811 if (!body) | 1220 if (!body) |
| 812 return Conversation_API_Error( | 1221 return Conversation_API_Error_With_Cookie( |
| 813 p_arena, "500", "serialize_failed", "Response exceeds memory limit"); | 1222 p_arena, "500", "serialize_failed", "Response exceeds memory limit", |
| 1223 new_guest_cookie); | |
| 814 size_t offset = 0; | 1224 size_t offset = 0; |
| 815 if (!Conversation_API_Append( | 1225 if (!Conversation_API_Append( |
| 816 body, | 1226 body, |
| 817 capacity, | 1227 capacity, |
| 818 &offset, | 1228 &offset, |
| 821 record.id, | 1231 record.id, |
| 822 escaped_title, | 1232 escaped_title, |
| 823 record.status, | 1233 record.status, |
| 824 (long long)record.created_at, | 1234 (long long)record.created_at, |
| 825 (long long)record.updated_at)) | 1235 (long long)record.updated_at)) |
| 826 return Conversation_API_Error( | 1236 return Conversation_API_Error_With_Cookie( |
| 827 p_arena, "500", "serialize_failed", "Response too large"); | 1237 p_arena, "500", "serialize_failed", "Response too large", |
| 1238 new_guest_cookie); | |
| 828 | 1239 |
| 829 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) | 1240 for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++) |
| 830 { | 1241 { |
| 831 Conversation_Turn *p_turn = &record.turns[i]; | 1242 Conversation_Turn *p_turn = &record.turns[i]; |
| 832 char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena); | 1243 char *role = Dowa_JSON_Escape_String(p_turn->role, 0, p_arena); |
| 855 error, | 1266 error, |
| 856 (long long)p_turn->input_tokens, | 1267 (long long)p_turn->input_tokens, |
| 857 (long long)p_turn->output_tokens, | 1268 (long long)p_turn->output_tokens, |
| 858 (long long)p_turn->created_at, | 1269 (long long)p_turn->created_at, |
| 859 (long long)p_turn->completed_at)) | 1270 (long long)p_turn->completed_at)) |
| 860 return Conversation_API_Error( | 1271 return Conversation_API_Error_With_Cookie( |
| 861 p_arena, "500", "serialize_failed", "Response too large"); | 1272 p_arena, "500", "serialize_failed", "Response too large", |
| 1273 new_guest_cookie); | |
| 862 } | 1274 } |
| 863 if (!Conversation_API_Append(body, capacity, &offset, "]}")) | 1275 if (!Conversation_API_Append(body, capacity, &offset, "]}")) |
| 864 return Conversation_API_Error( | 1276 return Conversation_API_Error_With_Cookie( |
| 865 p_arena, "500", "serialize_failed", "Response too large"); | 1277 p_arena, "500", "serialize_failed", "Response too large", |
| 866 return Conversation_API_JSON_Response(p_arena, "200", body); | 1278 new_guest_cookie); |
| 867 } | 1279 return Conversation_API_JSON_Response_With_Cookie( |
| 1280 p_arena, "200", body, new_guest_cookie); | |
| 1281 } | |
| 1282 | |
| 1283 /* ------------------------------------------------------------------ */ | |
| 1284 /* PATCH /api/conversations/:conversation_id */ | |
| 1285 /* ------------------------------------------------------------------ */ | |
| 868 | 1286 |
| 869 static Seobeo_Request_Entry *Conversation_API_Update( | 1287 static Seobeo_Request_Entry *Conversation_API_Update( |
| 870 Seobeo_Request_Entry *p_request, | 1288 Seobeo_Request_Entry *p_request, |
| 871 Dowa_Arena *p_arena) | 1289 Dowa_Arena *p_arena) |
| 872 { | 1290 { |
| 873 if (!Conversation_API_Is_Enabled()) | 1291 if (!g_conversation_store) |
| 874 return Conversation_API_Error( | 1292 return Conversation_API_Error( |
| 875 p_arena, "503", "inference_disabled", "Inference API is disabled"); | 1293 p_arena, "503", "store_unavailable", "Conversation store unavailable"); |
| 876 if (!Conversation_API_Is_Same_Origin(p_request)) | 1294 |
| 877 return Conversation_API_Error( | 1295 Auth_Principal principal; |
| 878 p_arena, "403", "origin_rejected", "Same-origin request required"); | 1296 boolean found = FALSE; |
| 1297 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found)) | |
| 1298 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 1299 if (!found) | |
| 1300 return Conversation_API_Error( | |
| 1301 p_arena, "401", "auth_required", "Bootstrap session first"); | |
| 1302 if (principal.must_change_password) | |
| 1303 return Conversation_API_Error( | |
| 1304 p_arena, "403", "password_change_required", | |
| 1305 "Password change required"); | |
| 1306 if (!Auth_API_Verify_CSRF(p_request, &principal)) | |
| 1307 return Conversation_API_Error( | |
| 1308 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required"); | |
| 1309 | |
| 1310 Conversation_Owner owner; | |
| 1311 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 1312 | |
| 879 const char *conversation_id = | 1313 const char *conversation_id = |
| 880 Conversation_API_Request_Value(p_request, ":conversation_id"); | 1314 Conversation_API_Request_Value(p_request, ":conversation_id"); |
| 881 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); | 1315 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); |
| 882 char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL; | 1316 char *title = object ? Dowa_JSON_Get_String(object, "title") : NULL; |
| 883 if (!conversation_id || !title) | 1317 if (!conversation_id || !title) |
| 885 p_arena, "400", "invalid_request", "Conversation ID and title required"); | 1319 p_arena, "400", "invalid_request", "Conversation ID and title required"); |
| 886 if (strlen(title) > CONVERSATION_TITLE_MAX) | 1320 if (strlen(title) > CONVERSATION_TITLE_MAX) |
| 887 return Conversation_API_Error( | 1321 return Conversation_API_Error( |
| 888 p_arena, "400", "invalid_title", "Title exceeds 200 bytes"); | 1322 p_arena, "400", "invalid_title", "Title exceeds 200 bytes"); |
| 889 | 1323 |
| 890 Conversation_Store_Result result = Conversation_Store_Update_Title( | 1324 Conversation_Store_Result result = Conversation_Store_Update_Title_Owned( |
| 891 g_conversation_store, conversation_id, title); | 1325 g_conversation_store, conversation_id, &owner, title); |
| 892 if (result == CONVERSATION_STORE_NOT_FOUND) | 1326 if (result == CONVERSATION_STORE_NOT_FOUND) |
| 893 return Conversation_API_Error( | 1327 return Conversation_API_Error( |
| 894 p_arena, "404", "not_found", "Conversation not found"); | 1328 p_arena, "404", "not_found", "Conversation not found"); |
| 895 if (result != CONVERSATION_STORE_OK) | 1329 if (result != CONVERSATION_STORE_OK) |
| 896 return Conversation_API_Error( | 1330 return Conversation_API_Error( |
| 897 p_arena, "500", "update_failed", "Unable to update conversation"); | 1331 p_arena, "500", "update_failed", "Unable to update conversation"); |
| 898 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}"); | 1332 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}"); |
| 899 } | 1333 } |
| 1334 | |
| 1335 /* ------------------------------------------------------------------ */ | |
| 1336 /* DELETE /api/conversations/:conversation_id */ | |
| 1337 /* ------------------------------------------------------------------ */ | |
| 900 | 1338 |
| 901 static Seobeo_Request_Entry *Conversation_API_Delete( | 1339 static Seobeo_Request_Entry *Conversation_API_Delete( |
| 902 Seobeo_Request_Entry *p_request, | 1340 Seobeo_Request_Entry *p_request, |
| 903 Dowa_Arena *p_arena) | 1341 Dowa_Arena *p_arena) |
| 904 { | 1342 { |
| 905 if (!Conversation_API_Is_Enabled()) | 1343 if (!g_conversation_store) |
| 906 return Conversation_API_Error( | 1344 return Conversation_API_Error( |
| 907 p_arena, "503", "inference_disabled", "Inference API is disabled"); | 1345 p_arena, "503", "store_unavailable", "Conversation store unavailable"); |
| 908 if (!Conversation_API_Is_Same_Origin(p_request)) | 1346 |
| 909 return Conversation_API_Error( | 1347 Auth_Principal principal; |
| 910 p_arena, "403", "origin_rejected", "Same-origin request required"); | 1348 boolean found = FALSE; |
| 1349 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found)) | |
| 1350 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 1351 if (!found) | |
| 1352 return Conversation_API_Error( | |
| 1353 p_arena, "401", "auth_required", "Bootstrap session first"); | |
| 1354 if (principal.must_change_password) | |
| 1355 return Conversation_API_Error( | |
| 1356 p_arena, "403", "password_change_required", | |
| 1357 "Password change required"); | |
| 1358 if (!Auth_API_Verify_CSRF(p_request, &principal)) | |
| 1359 return Conversation_API_Error( | |
| 1360 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required"); | |
| 1361 | |
| 1362 Conversation_Owner owner; | |
| 1363 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 1364 | |
| 911 const char *conversation_id = | 1365 const char *conversation_id = |
| 912 Conversation_API_Request_Value(p_request, ":conversation_id"); | 1366 Conversation_API_Request_Value(p_request, ":conversation_id"); |
| 913 if (!conversation_id) | 1367 if (!conversation_id) |
| 914 return Conversation_API_Error( | 1368 return Conversation_API_Error( |
| 915 p_arena, "400", "missing_id", "Conversation ID is required"); | 1369 p_arena, "400", "missing_id", "Conversation ID is required"); |
| 916 Conversation_Store_Result result = Conversation_Store_Delete( | 1370 |
| 917 g_conversation_store, conversation_id); | 1371 Conversation_Store_Result result = Conversation_Store_Delete_Owned( |
| 1372 g_conversation_store, conversation_id, &owner); | |
| 918 if (result == CONVERSATION_STORE_NOT_FOUND) | 1373 if (result == CONVERSATION_STORE_NOT_FOUND) |
| 919 return Conversation_API_Error( | 1374 return Conversation_API_Error( |
| 920 p_arena, "404", "not_found", "Conversation not found"); | 1375 p_arena, "404", "not_found", "Conversation not found"); |
| 921 if (result != CONVERSATION_STORE_OK) | 1376 if (result != CONVERSATION_STORE_OK) |
| 922 return Conversation_API_Error( | 1377 return Conversation_API_Error( |
| 923 p_arena, "500", "delete_failed", "Unable to delete conversation"); | 1378 p_arena, "500", "delete_failed", "Unable to delete conversation"); |
| 1379 | |
| 924 if (Inference_Bridge_Is_Ready(g_inference_bridge)) | 1380 if (Inference_Bridge_Is_Ready(g_inference_bridge)) |
| 925 { | 1381 { |
| 926 char request_id[37]; | 1382 char request_id[37]; |
| 927 if (Conversation_Store_Generate_UUID(request_id)) | 1383 if (Conversation_Store_Generate_UUID(request_id)) |
| 928 Inference_Bridge_Delete_Conversation( | 1384 Inference_Bridge_Delete_Conversation( |
| 933 Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena); | 1389 Dowa_HashMap_Push_Arena(p_response, "status", "204", p_arena); |
| 934 Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena); | 1390 Dowa_HashMap_Push_Arena(p_response, "body", "", p_arena); |
| 935 return p_response; | 1391 return p_response; |
| 936 } | 1392 } |
| 937 | 1393 |
| 1394 /* ------------------------------------------------------------------ */ | |
| 1395 /* POST /api/conversations/claim (body: {"conversationId":"<uuid>"}) */ | |
| 1396 /* ID stays in request body — never appears in URL or request log. */ | |
| 1397 /* ------------------------------------------------------------------ */ | |
| 1398 | |
| 1399 static Seobeo_Request_Entry *Conversation_API_Claim_Body( | |
| 1400 Seobeo_Request_Entry *p_request, | |
| 1401 Dowa_Arena *p_arena) | |
| 1402 { | |
| 1403 if (!g_conversation_store) | |
| 1404 return Conversation_API_Error( | |
| 1405 p_arena, "503", "store_unavailable", "Conversation store unavailable"); | |
| 1406 | |
| 1407 Auth_Principal principal; | |
| 1408 boolean found = FALSE; | |
| 1409 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found)) | |
| 1410 return Conversation_API_Error(p_arena, "500", "internal_error", "Session error"); | |
| 1411 if (!found) | |
| 1412 return Conversation_API_Error( | |
| 1413 p_arena, "401", "auth_required", "Bootstrap session first"); | |
| 1414 /* Guests may not claim; forced-password-change users blocked */ | |
| 1415 if (principal.kind != AUTH_PRINCIPAL_USER) | |
| 1416 return Conversation_API_Error( | |
| 1417 p_arena, "403", "forbidden", "Must be authenticated to claim"); | |
| 1418 if (principal.must_change_password) | |
| 1419 return Conversation_API_Error( | |
| 1420 p_arena, "403", "password_change_required", | |
| 1421 "Password change required"); | |
| 1422 if (!Auth_API_Verify_CSRF(p_request, &principal)) | |
| 1423 return Conversation_API_Error( | |
| 1424 p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required"); | |
| 1425 | |
| 1426 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); | |
| 1427 if (!object) | |
| 1428 return Conversation_API_Error( | |
| 1429 p_arena, "400", "invalid_json", "Request body must be a JSON object"); | |
| 1430 const char *conversation_id = Dowa_JSON_Get_String(object, "conversationId"); | |
| 1431 if (!conversation_id || conversation_id[0] == '\0') | |
| 1432 return Conversation_API_Error( | |
| 1433 p_arena, "400", "missing_id", "conversationId is required"); | |
| 1434 if (strlen(conversation_id) > 36) | |
| 1435 return Conversation_API_Error( | |
| 1436 p_arena, "400", "invalid_id", "conversationId is invalid"); | |
| 1437 | |
| 1438 Conversation_Store_Result result = Conversation_Store_Claim_Legacy( | |
| 1439 g_conversation_store, conversation_id, principal.user_id); | |
| 1440 if (result == CONVERSATION_STORE_NOT_FOUND) | |
| 1441 return Conversation_API_Error( | |
| 1442 p_arena, "404", "not_found", "Conversation not found"); | |
| 1443 if (result == CONVERSATION_STORE_CONFLICT) | |
| 1444 return Conversation_API_Error( | |
| 1445 p_arena, "409", "already_owned", "Conversation is already owned"); | |
| 1446 if (result != CONVERSATION_STORE_OK) | |
| 1447 return Conversation_API_Error( | |
| 1448 p_arena, "500", "claim_failed", "Unable to claim conversation"); | |
| 1449 | |
| 1450 return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}"); | |
| 1451 } | |
| 1452 | |
| 1453 /* ------------------------------------------------------------------ */ | |
| 1454 /* GET /api/inference/health */ | |
| 1455 /* ------------------------------------------------------------------ */ | |
| 1456 | |
| 938 static Seobeo_Request_Entry *Conversation_API_Health( | 1457 static Seobeo_Request_Entry *Conversation_API_Health( |
| 939 Seobeo_Request_Entry *p_request, | 1458 Seobeo_Request_Entry *p_request, |
| 940 Dowa_Arena *p_arena) | 1459 Dowa_Arena *p_arena) |
| 941 { | 1460 { |
| 942 (void)p_request; | 1461 (void)p_request; |
| 943 boolean ready = | 1462 boolean ready = |
| 944 Conversation_API_Is_Enabled() && | 1463 Conversation_API_Is_Inference_Ready() && |
| 945 g_conversation_store && | 1464 g_conversation_store && |
| 946 Inference_Bridge_Is_Ready(g_inference_bridge); | 1465 Inference_Bridge_Is_Ready(g_inference_bridge); |
| 947 return Conversation_API_JSON_Response( | 1466 return Conversation_API_JSON_Response( |
| 948 p_arena, | 1467 p_arena, |
| 949 ready ? "200" : "503", | 1468 ready ? "200" : "503", |
| 950 ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}"); | 1469 ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}"); |
| 951 } | 1470 } |
| 952 | 1471 |
| 1472 /* ------------------------------------------------------------------ */ | |
| 1473 /* POST /api/conversations/:conversation_id/turns (streaming) */ | |
| 1474 /* ------------------------------------------------------------------ */ | |
| 1475 | |
| 1476 /* | |
| 1477 * SSE client-disconnect callback. | |
| 1478 * Called by Seobeo_SSE_Server_Detach_Handle after g_sse_mutex is released. | |
| 1479 * Finds the pending turn for the disconnected stream and finalizes it once. | |
| 1480 * Must NOT hold g_sse_mutex when called; acquires g_pending_mutex. | |
| 1481 */ | |
| 1482 static void Conversation_API_On_SSE_Detach( | |
| 1483 Seobeo_SSE_Stream *p_stream, | |
| 1484 void *ctx) | |
| 1485 { | |
| 1486 (void)ctx; | |
| 1487 pthread_mutex_lock(&g_pending_mutex); | |
| 1488 for (Pending_Turn *p_turn = g_pending_turns; | |
| 1489 p_turn; | |
| 1490 p_turn = p_turn->p_next) | |
| 1491 { | |
| 1492 if (p_turn->p_stream == p_stream && !p_turn->finalized) | |
| 1493 { | |
| 1494 p_turn->aborted = TRUE; | |
| 1495 snprintf(p_turn->error_message, sizeof(p_turn->error_message), | |
| 1496 "Client disconnected"); | |
| 1497 Conversation_API_Finalize_Pending(p_turn); | |
| 1498 break; | |
| 1499 } | |
| 1500 } | |
| 1501 pthread_mutex_unlock(&g_pending_mutex); | |
| 1502 } | |
| 1503 | |
| 953 static void Conversation_API_Turn_Stream( | 1504 static void Conversation_API_Turn_Stream( |
| 954 Seobeo_Handle *p_handle, | 1505 Seobeo_Handle *p_handle, |
| 955 Seobeo_Request_Entry *p_request, | 1506 Seobeo_Request_Entry *p_request, |
| 956 Dowa_Arena *p_arena) | 1507 Dowa_Arena *p_arena) |
| 957 { | 1508 { |
| 958 if (!Conversation_API_Is_Enabled()) | 1509 /* Resolve existing identity first so we can apply controls per-principal. */ |
| 1510 Auth_Principal principal; | |
| 1511 boolean found = FALSE; | |
| 1512 if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found)) | |
| 1513 { | |
| 1514 Conversation_API_Send_Stream_Error( | |
| 1515 p_handle, 500, "internal_error", "Session error"); | |
| 1516 return; | |
| 1517 } | |
| 1518 if (!found) | |
| 1519 { | |
| 1520 Conversation_API_Send_Stream_Error( | |
| 1521 p_handle, 401, "auth_required", "Bootstrap session first"); | |
| 1522 return; | |
| 1523 } | |
| 1524 if (principal.must_change_password) | |
| 1525 { | |
| 1526 Conversation_API_Send_Stream_Error( | |
| 1527 p_handle, 403, "password_change_required", | |
| 1528 "Password change required"); | |
| 1529 return; | |
| 1530 } | |
| 1531 if (!Auth_API_Verify_CSRF(p_request, &principal)) | |
| 1532 { | |
| 1533 Conversation_API_Send_Stream_Error( | |
| 1534 p_handle, 403, "csrf_invalid", "Same-origin and CSRF token required"); | |
| 1535 return; | |
| 1536 } | |
| 1537 | |
| 1538 /* Guests require inference to be explicitly enabled; authenticated users | |
| 1539 * always proceed subject to the global bridge-ready check below. */ | |
| 1540 if (principal.kind == AUTH_PRINCIPAL_GUEST && | |
| 1541 !Conversation_API_Is_Inference_Ready()) | |
| 959 { | 1542 { |
| 960 Conversation_API_Send_Stream_Error( | 1543 Conversation_API_Send_Stream_Error( |
| 961 p_handle, 503, "inference_disabled", "Inference API is disabled"); | 1544 p_handle, 503, "inference_disabled", "Inference API is disabled"); |
| 962 return; | 1545 return; |
| 963 } | 1546 } |
| 964 if (!Conversation_API_Is_Same_Origin(p_request)) | |
| 965 { | |
| 966 Conversation_API_Send_Stream_Error( | |
| 967 p_handle, 403, "origin_rejected", "Same-origin request required"); | |
| 968 return; | |
| 969 } | |
| 970 if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge)) | 1547 if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge)) |
| 971 { | 1548 { |
| 972 Conversation_API_Send_Stream_Error( | 1549 Conversation_API_Send_Stream_Error( |
| 973 p_handle, 503, "inference_unavailable", "Inference runtime unavailable"); | 1550 p_handle, 503, "inference_unavailable", "Inference runtime unavailable"); |
| 974 return; | 1551 return; |
| 975 } | 1552 } |
| 1553 | |
| 1554 /* Map principal to owner — copy IDs before arena may expire (req 8) */ | |
| 1555 Conversation_Owner owner; | |
| 1556 Conversation_API_Owner_From_Principal(&principal, &owner); | |
| 1557 | |
| 976 const char *conversation_id = | 1558 const char *conversation_id = |
| 977 Conversation_API_Request_Value(p_request, ":conversation_id"); | 1559 Conversation_API_Request_Value(p_request, ":conversation_id"); |
| 978 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); | 1560 Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena); |
| 979 char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL; | 1561 char *prompt = object ? Dowa_JSON_Get_String(object, "prompt") : NULL; |
| 980 if (!conversation_id || !prompt || prompt[0] == '\0') | 1562 if (!conversation_id || !prompt || prompt[0] == '\0') |
| 990 p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB"); | 1572 p_handle, 413, "prompt_too_large", "Prompt exceeds 32 KiB"); |
| 991 return; | 1573 return; |
| 992 } | 1574 } |
| 993 if (!Conversation_API_Acquire_Turn_Slot()) | 1575 if (!Conversation_API_Acquire_Turn_Slot()) |
| 994 { | 1576 { |
| 995 Conversation_API_Send_Stream_Error( | 1577 /* Temporary capacity limit — advise client to retry in 5 s. */ |
| 996 p_handle, 429, "rate_limited", "Inference capacity exhausted"); | 1578 static const char rate_body[] = |
| 1579 "{\"error\":{\"code\":\"rate_limited\"," | |
| 1580 "\"message\":\"Inference capacity exhausted\"}}"; | |
| 1581 char rate_header[512]; | |
| 1582 snprintf(rate_header, sizeof(rate_header), | |
| 1583 "HTTP/1.1 429 Too Many Requests\r\n" | |
| 1584 "Content-Type: application/json; charset=utf-8\r\n" | |
| 1585 "Content-Length: %zu\r\n" | |
| 1586 "Retry-After: 5\r\n" | |
| 1587 "Connection: close\r\n" | |
| 1588 "\r\n", | |
| 1589 sizeof(rate_body) - 1); | |
| 1590 Seobeo_Handle_Queue( | |
| 1591 p_handle, (const uint8 *)rate_header, (uint32)strlen(rate_header)); | |
| 1592 Seobeo_Handle_Queue( | |
| 1593 p_handle, (const uint8 *)rate_body, | |
| 1594 (uint32)(sizeof(rate_body) - 1)); | |
| 1595 Seobeo_Handle_Flush(p_handle); | |
| 997 return; | 1596 return; |
| 998 } | 1597 } |
| 999 | 1598 |
| 1000 char request_id[37]; | 1599 char request_id[37]; |
| 1001 if (!Conversation_Store_Generate_UUID(request_id)) | 1600 if (!Conversation_Store_Generate_UUID(request_id)) |
| 1003 Conversation_API_Release_Turn_Slot(); | 1602 Conversation_API_Release_Turn_Slot(); |
| 1004 Conversation_API_Send_Stream_Error( | 1603 Conversation_API_Send_Stream_Error( |
| 1005 p_handle, 500, "id_failed", "Unable to create request ID"); | 1604 p_handle, 500, "id_failed", "Unable to create request ID"); |
| 1006 return; | 1605 return; |
| 1007 } | 1606 } |
| 1008 Conversation_Store_Result result = Conversation_Store_Begin_Turn( | 1607 |
| 1009 g_conversation_store, | 1608 /* --- Guest quota reservation (before persisting turn) --- */ |
| 1010 conversation_id, | 1609 boolean is_guest_reserved = FALSE; |
| 1011 request_id, | 1610 char quota_guest_id[37] = {0}; |
| 1012 prompt); | 1611 if (principal.kind == AUTH_PRINCIPAL_GUEST) |
| 1612 { | |
| 1613 Auth_Store *p_auth_store = Auth_API_Get_Store(); | |
| 1614 if (!p_auth_store) | |
| 1615 { | |
| 1616 Conversation_API_Release_Turn_Slot(); | |
| 1617 Conversation_API_Send_Stream_Error( | |
| 1618 p_handle, 503, "store_unavailable", "Auth store unavailable"); | |
| 1619 return; | |
| 1620 } | |
| 1621 | |
| 1622 int64 now_unix = (int64)time(NULL); | |
| 1623 int64 window_start = conv__utc_window_start(now_unix); | |
| 1624 int64 resets_at = window_start + 86400LL; | |
| 1625 | |
| 1626 Auth_Store_Guest_Quota_Result quota_result = Auth_Store_Guest_Reserve( | |
| 1627 p_auth_store, | |
| 1628 principal.guest_id, | |
| 1629 request_id, | |
| 1630 window_start, | |
| 1631 g_guest_request_output_tokens, | |
| 1632 g_guest_daily_turns, | |
| 1633 g_guest_daily_output_tokens, | |
| 1634 now_unix + 3600); | |
| 1635 | |
| 1636 if (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED || | |
| 1637 quota_result == AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED) | |
| 1638 { | |
| 1639 /* Fetch current usage for 429 payload. */ | |
| 1640 Auth_Store_Guest_Usage usage; | |
| 1641 memset(&usage, 0, sizeof(usage)); | |
| 1642 Auth_Store_Guest_Get_Usage( | |
| 1643 p_auth_store, principal.guest_id, window_start, &usage); | |
| 1644 | |
| 1645 int64 turns_remaining = | |
| 1646 g_guest_daily_turns - usage.turns_used; | |
| 1647 if (turns_remaining < 0) turns_remaining = 0; | |
| 1648 int64 tokens_remaining = | |
| 1649 g_guest_daily_output_tokens | |
| 1650 - usage.output_tokens_used | |
| 1651 - usage.output_tokens_reserved; | |
| 1652 if (tokens_remaining < 0) tokens_remaining = 0; | |
| 1653 | |
| 1654 const char *code = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED) | |
| 1655 ? "guest_quota_turns_exhausted" | |
| 1656 : "guest_quota_tokens_exhausted"; | |
| 1657 const char *message = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED) | |
| 1658 ? "Daily turn limit reached" | |
| 1659 : "Daily output token limit reached"; | |
| 1660 | |
| 1661 char quota_body[1024]; | |
| 1662 int qlen = snprintf( | |
| 1663 quota_body, sizeof(quota_body), | |
| 1664 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"," | |
| 1665 "\"quota\":{\"turnsLimit\":%lld,\"turnsUsed\":%lld," | |
| 1666 "\"turnsRemaining\":%lld,\"outputTokensLimit\":%lld," | |
| 1667 "\"outputTokensUsed\":%lld,\"outputTokensReserved\":%lld," | |
| 1668 "\"outputTokensRemaining\":%lld,\"resetsAt\":%lld}}}", | |
| 1669 code, message, | |
| 1670 (long long)g_guest_daily_turns, | |
| 1671 (long long)usage.turns_used, | |
| 1672 (long long)turns_remaining, | |
| 1673 (long long)g_guest_daily_output_tokens, | |
| 1674 (long long)usage.output_tokens_used, | |
| 1675 (long long)usage.output_tokens_reserved, | |
| 1676 (long long)tokens_remaining, | |
| 1677 (long long)resets_at); | |
| 1678 if (qlen <= 0 || (size_t)qlen >= sizeof(quota_body)) | |
| 1679 { | |
| 1680 Conversation_API_Release_Turn_Slot(); | |
| 1681 Conversation_API_Send_Stream_Error( | |
| 1682 p_handle, 429, code, message); | |
| 1683 return; | |
| 1684 } | |
| 1685 char header[512]; | |
| 1686 Seobeo_Web_Header_Generate(header, 429, | |
| 1687 "application/json; charset=utf-8", qlen); | |
| 1688 Seobeo_Handle_Queue( | |
| 1689 p_handle, (const uint8 *)header, (uint32)strlen(header)); | |
| 1690 Seobeo_Handle_Queue( | |
| 1691 p_handle, (const uint8 *)quota_body, (uint32)qlen); | |
| 1692 Seobeo_Handle_Flush(p_handle); | |
| 1693 Conversation_API_Release_Turn_Slot(); | |
| 1694 return; | |
| 1695 } | |
| 1696 if (quota_result != AUTH_STORE_GUEST_QUOTA_OK) | |
| 1697 { | |
| 1698 Conversation_API_Release_Turn_Slot(); | |
| 1699 Conversation_API_Send_Stream_Error( | |
| 1700 p_handle, 500, "quota_error", "Unable to check guest quota"); | |
| 1701 return; | |
| 1702 } | |
| 1703 is_guest_reserved = TRUE; | |
| 1704 snprintf(quota_guest_id, sizeof(quota_guest_id), "%s", | |
| 1705 principal.guest_id); | |
| 1706 } | |
| 1707 | |
| 1708 Conversation_Store_Result result = Conversation_Store_Begin_Turn_Owned( | |
| 1709 g_conversation_store, conversation_id, &owner, request_id, prompt); | |
| 1013 if (result == CONVERSATION_STORE_NOT_FOUND) | 1710 if (result == CONVERSATION_STORE_NOT_FOUND) |
| 1014 { | 1711 { |
| 1712 if (is_guest_reserved) | |
| 1713 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id); | |
| 1015 Conversation_API_Release_Turn_Slot(); | 1714 Conversation_API_Release_Turn_Slot(); |
| 1016 Conversation_API_Send_Stream_Error( | 1715 Conversation_API_Send_Stream_Error( |
| 1017 p_handle, 404, "not_found", "Conversation not found"); | 1716 p_handle, 404, "not_found", "Conversation not found"); |
| 1018 return; | 1717 return; |
| 1019 } | 1718 } |
| 1020 if (result == CONVERSATION_STORE_CONFLICT) | 1719 if (result == CONVERSATION_STORE_CONFLICT) |
| 1021 { | 1720 { |
| 1721 if (is_guest_reserved) | |
| 1722 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id); | |
| 1022 Conversation_API_Release_Turn_Slot(); | 1723 Conversation_API_Release_Turn_Slot(); |
| 1023 Conversation_API_Send_Stream_Error( | 1724 Conversation_API_Send_Stream_Error( |
| 1024 p_handle, 409, "turn_in_progress", "Conversation already has an active turn"); | 1725 p_handle, 409, "turn_in_progress", "Conversation already has an active turn"); |
| 1025 return; | 1726 return; |
| 1026 } | 1727 } |
| 1027 if (result != CONVERSATION_STORE_OK) | 1728 if (result != CONVERSATION_STORE_OK) |
| 1028 { | 1729 { |
| 1730 if (is_guest_reserved) | |
| 1731 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id); | |
| 1029 Conversation_API_Release_Turn_Slot(); | 1732 Conversation_API_Release_Turn_Slot(); |
| 1030 Conversation_API_Send_Stream_Error( | 1733 Conversation_API_Send_Stream_Error( |
| 1031 p_handle, 500, "turn_failed", "Unable to persist turn"); | 1734 p_handle, 500, "turn_failed", "Unable to persist turn"); |
| 1032 return; | 1735 return; |
| 1033 } | 1736 } |
| 1035 Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach( | 1738 Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach( |
| 1036 p_handle, | 1739 p_handle, |
| 1037 "/api/conversations/turns"); | 1740 "/api/conversations/turns"); |
| 1038 if (!p_stream || !Seobeo_SSE_Retain(p_stream)) | 1741 if (!p_stream || !Seobeo_SSE_Retain(p_stream)) |
| 1039 { | 1742 { |
| 1743 if (is_guest_reserved) | |
| 1744 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id); | |
| 1040 Conversation_API_Release_Turn_Slot(); | 1745 Conversation_API_Release_Turn_Slot(); |
| 1041 Conversation_Store_Fail_Turn( | 1746 Conversation_Store_Fail_Turn( |
| 1042 g_conversation_store, | 1747 g_conversation_store, |
| 1043 conversation_id, | 1748 conversation_id, |
| 1044 request_id, | 1749 request_id, |
| 1051 } | 1756 } |
| 1052 | 1757 |
| 1053 Pending_Turn *p_turn = calloc(1, sizeof(*p_turn)); | 1758 Pending_Turn *p_turn = calloc(1, sizeof(*p_turn)); |
| 1054 if (!p_turn) | 1759 if (!p_turn) |
| 1055 { | 1760 { |
| 1761 if (is_guest_reserved) | |
| 1762 Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id); | |
| 1056 Conversation_API_Release_Turn_Slot(); | 1763 Conversation_API_Release_Turn_Slot(); |
| 1057 Conversation_Store_Fail_Turn( | 1764 Conversation_Store_Fail_Turn( |
| 1058 g_conversation_store, | 1765 g_conversation_store, |
| 1059 conversation_id, | 1766 conversation_id, |
| 1060 request_id, | 1767 request_id, |
| 1071 snprintf( | 1778 snprintf( |
| 1072 p_turn->conversation_id, | 1779 p_turn->conversation_id, |
| 1073 sizeof(p_turn->conversation_id), | 1780 sizeof(p_turn->conversation_id), |
| 1074 "%s", | 1781 "%s", |
| 1075 conversation_id); | 1782 conversation_id); |
| 1783 /* Copy owner and quota fields before arena expires (req 8) */ | |
| 1784 p_turn->owner = owner; | |
| 1785 p_turn->is_guest_reserved = is_guest_reserved; | |
| 1786 p_turn->reserved_output_tokens = | |
| 1787 is_guest_reserved ? g_guest_request_output_tokens : 0; | |
| 1788 snprintf(p_turn->guest_id, sizeof(p_turn->guest_id), "%s", quota_guest_id); | |
| 1076 p_turn->p_stream = p_stream; | 1789 p_turn->p_stream = p_stream; |
| 1790 | |
| 1791 /* Register detach callback so client disconnect triggers finalization. */ | |
| 1792 Seobeo_SSE_Set_Detach_Callback(p_stream, Conversation_API_On_SSE_Detach, NULL); | |
| 1077 | 1793 |
| 1078 pthread_mutex_lock(&g_pending_mutex); | 1794 pthread_mutex_lock(&g_pending_mutex); |
| 1079 p_turn->p_next = g_pending_turns; | 1795 p_turn->p_next = g_pending_turns; |
| 1080 g_pending_turns = p_turn; | 1796 g_pending_turns = p_turn; |
| 1081 pthread_mutex_unlock(&g_pending_mutex); | 1797 pthread_mutex_unlock(&g_pending_mutex); |
| 1101 } | 1817 } |
| 1102 pthread_mutex_unlock(&g_pending_mutex); | 1818 pthread_mutex_unlock(&g_pending_mutex); |
| 1103 } | 1819 } |
| 1104 } | 1820 } |
| 1105 | 1821 |
| 1106 boolean Conversation_API_Init(const char *database_path) | 1822 boolean Conversation_API_Init( |
| 1823 const char *database_path, | |
| 1824 const Conversation_API_Guest_Policy *p_policy) | |
| 1107 { | 1825 { |
| 1108 if (g_conversation_store) | 1826 if (g_conversation_store) |
| 1109 return TRUE; | 1827 return TRUE; |
| 1110 const char *allow_anonymous = getenv( | 1828 |
| 1111 "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE"); | 1829 if (p_policy) |
| 1112 g_anonymous_inference_enabled = | 1830 { |
| 1113 allow_anonymous && | 1831 /* Validate policy ranges. */ |
| 1114 (strcmp(allow_anonymous, "1") == 0 || | 1832 if (p_policy->daily_turns < CONV_GUEST_DAILY_TURNS_MIN || |
| 1115 strcasecmp(allow_anonymous, "true") == 0); | 1833 p_policy->daily_turns > CONV_GUEST_DAILY_TURNS_MAX) |
| 1834 { | |
| 1835 Seobeo_Log(SEOBEO_ERROR, | |
| 1836 "[CONV] daily_turns must be %d..%d\n", | |
| 1837 CONV_GUEST_DAILY_TURNS_MIN, CONV_GUEST_DAILY_TURNS_MAX); | |
| 1838 return FALSE; | |
| 1839 } | |
| 1840 if (p_policy->daily_output_tokens < CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN || | |
| 1841 p_policy->daily_output_tokens > CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX) | |
| 1842 { | |
| 1843 Seobeo_Log(SEOBEO_ERROR, | |
| 1844 "[CONV] daily_output_tokens must be %d..%d\n", | |
| 1845 CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN, | |
| 1846 CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX); | |
| 1847 return FALSE; | |
| 1848 } | |
| 1849 if (p_policy->request_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN || | |
| 1850 p_policy->request_output_tokens > p_policy->daily_output_tokens) | |
| 1851 { | |
| 1852 Seobeo_Log(SEOBEO_ERROR, | |
| 1853 "[CONV] request_output_tokens must be 1..%lld\n", | |
| 1854 (long long)p_policy->daily_output_tokens); | |
| 1855 return FALSE; | |
| 1856 } | |
| 1857 g_guest_inference_enabled = p_policy->guest_inference_enabled; | |
| 1858 g_guest_daily_turns = p_policy->daily_turns; | |
| 1859 g_guest_daily_output_tokens = p_policy->daily_output_tokens; | |
| 1860 g_guest_request_output_tokens = p_policy->request_output_tokens; | |
| 1861 } | |
| 1862 else | |
| 1863 { | |
| 1864 /* No explicit policy: read from environment (backwards compat). */ | |
| 1865 const char *allow_guest = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE"); | |
| 1866 if (!allow_guest) | |
| 1867 { | |
| 1868 const char *allow_anon = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE"); | |
| 1869 if (allow_anon) | |
| 1870 { | |
| 1871 Seobeo_Log(SEOBEO_WARNING, | |
| 1872 "[CONV] MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE is deprecated;" | |
| 1873 " use MRJUNEJUNE_ALLOW_GUEST_INFERENCE\n"); | |
| 1874 allow_guest = allow_anon; | |
| 1875 } | |
| 1876 } | |
| 1877 g_guest_inference_enabled = | |
| 1878 allow_guest && | |
| 1879 (strcmp(allow_guest, "1") == 0 || | |
| 1880 strcasecmp(allow_guest, "true") == 0); | |
| 1881 | |
| 1882 g_guest_request_output_tokens = | |
| 1883 g_guest_daily_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT | |
| 1884 ? g_guest_daily_output_tokens | |
| 1885 : CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT; | |
| 1886 } | |
| 1887 | |
| 1116 g_conversation_store = Conversation_Store_Create(database_path); | 1888 g_conversation_store = Conversation_Store_Create(database_path); |
| 1117 return g_conversation_store != NULL; | 1889 return g_conversation_store != NULL; |
| 1118 } | 1890 } |
| 1119 | 1891 |
| 1120 boolean Conversation_API_Enable_Inference( | 1892 boolean Conversation_API_Enable_Inference( |
| 1139 return FALSE; | 1911 return FALSE; |
| 1140 } | 1912 } |
| 1141 return TRUE; | 1913 return TRUE; |
| 1142 } | 1914 } |
| 1143 | 1915 |
| 1916 /* Guest-to-user transfer hook registered with Auth_API on init */ | |
| 1917 static boolean Conversation_API_Guest_Transfer_Hook( | |
| 1918 const char *guest_id, | |
| 1919 const char *user_id, | |
| 1920 void *context) | |
| 1921 { | |
| 1922 (void)context; | |
| 1923 if (!g_conversation_store) | |
| 1924 return FALSE; | |
| 1925 | |
| 1926 /* Atomically transfer conversation ownership AND clear outstanding quota | |
| 1927 * reservations in one transaction (auth and conv tables share the same | |
| 1928 * SQLite file, so the write lock covers both). */ | |
| 1929 return Conversation_Store_Transfer_Guest_To_User_Atomic( | |
| 1930 g_conversation_store, guest_id, user_id) == CONVERSATION_STORE_OK; | |
| 1931 } | |
| 1932 | |
| 1144 void Conversation_API_Register_Routes(void) | 1933 void Conversation_API_Register_Routes(void) |
| 1145 { | 1934 { |
| 1935 /* Wire guest-to-user transfer on login */ | |
| 1936 Auth_API_Register_Guest_Transfer_Hook( | |
| 1937 Conversation_API_Guest_Transfer_Hook, NULL); | |
| 1938 | |
| 1939 /* Wire quota callback for the session endpoint. */ | |
| 1940 Auth_API_Register_Guest_Quota_Cb(conv_guest_quota_cb); | |
| 1941 | |
| 1146 Seobeo_Router_Register( | 1942 Seobeo_Router_Register( |
| 1147 "GET", "/api/inference/health", Conversation_API_Health); | 1943 "GET", "/api/inference/health", Conversation_API_Health); |
| 1944 Seobeo_Router_Register( | |
| 1945 "GET", "/api/conversations", Conversation_API_List); | |
| 1148 Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create); | 1946 Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create); |
| 1947 /* Body-based claim: ID stays in request body, not in URL or logs. */ | |
| 1948 Seobeo_Router_Register( | |
| 1949 "POST", "/api/conversations/claim", Conversation_API_Claim_Body); | |
| 1149 Seobeo_Router_Register( | 1950 Seobeo_Router_Register( |
| 1150 "GET", "/api/conversations/:conversation_id", Conversation_API_Get); | 1951 "GET", "/api/conversations/:conversation_id", Conversation_API_Get); |
| 1151 Seobeo_Router_Register( | 1952 Seobeo_Router_Register( |
| 1152 "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update); | 1953 "PATCH", "/api/conversations/:conversation_id", Conversation_API_Update); |
| 1153 Seobeo_Router_Register( | 1954 Seobeo_Router_Register( |
| 1165 pthread_mutex_lock(&g_pending_mutex); | 1966 pthread_mutex_lock(&g_pending_mutex); |
| 1166 while (g_pending_turns) | 1967 while (g_pending_turns) |
| 1167 { | 1968 { |
| 1168 Pending_Turn *p_turn = g_pending_turns; | 1969 Pending_Turn *p_turn = g_pending_turns; |
| 1169 g_pending_turns = p_turn->p_next; | 1970 g_pending_turns = p_turn->p_next; |
| 1971 /* Release guest quota reservation on server shutdown (keep turn charge). */ | |
| 1972 if (p_turn->is_guest_reserved) | |
| 1973 { | |
| 1974 Auth_Store *p_auth_store = Auth_API_Get_Store(); | |
| 1975 if (p_auth_store) | |
| 1976 Auth_Store_Guest_Release(p_auth_store, p_turn->request_id); | |
| 1977 } | |
| 1170 Conversation_Store_Fail_Turn( | 1978 Conversation_Store_Fail_Turn( |
| 1171 g_conversation_store, | 1979 g_conversation_store, |
| 1172 p_turn->conversation_id, | 1980 p_turn->conversation_id, |
| 1173 p_turn->request_id, | 1981 p_turn->request_id, |
| 1174 "Server shutdown", | 1982 "Server shutdown", |
| 1180 Conversation_API_Release_Turn_Slot(); | 1988 Conversation_API_Release_Turn_Slot(); |
| 1181 } | 1989 } |
| 1182 pthread_mutex_unlock(&g_pending_mutex); | 1990 pthread_mutex_unlock(&g_pending_mutex); |
| 1183 Conversation_Store_Destroy(g_conversation_store); | 1991 Conversation_Store_Destroy(g_conversation_store); |
| 1184 g_conversation_store = NULL; | 1992 g_conversation_store = NULL; |
| 1185 g_anonymous_inference_enabled = FALSE; | 1993 g_guest_inference_enabled = FALSE; |
| 1186 } | 1994 g_guest_daily_turns = CONV_GUEST_DAILY_TURNS_DEFAULT; |
| 1995 g_guest_daily_output_tokens = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT; | |
| 1996 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT; | |
| 1997 } |