CMSDK 2.0.1
Cross-platform C++ base library and SDK for the Psyclone AIOS platform
Loading...
Searching...
No Matches
LLMConnection.cpp
Go to the documentation of this file.
1#include "LLMConnection.h"
2#include "NetworkManager.h"
3#include "HTML.h"
4#include "jsmn.h"
5#include "StreamDecoders.h"
6
7#include <cstdio>
8#include <cstdlib>
9#include <cstring>
10
11namespace cmlabs{
12
13const char* LLMResultText(LLMResult result) {
14 switch (result) {
15 case LLM_OK: return "OK";
16 case LLM_ERR_NOT_CONFIGURED: return "Not configured";
17 case LLM_ERR_NOT_CONNECTED: return "Not connected";
18 case LLM_ERR_NOT_IMPLEMENTED: return "Not implemented";
19 case LLM_ERR_CONFIG_EMPTY: return "Config: empty <llm> element";
20 case LLM_ERR_CONFIG_NOT_LLM: return "Config: not an <llm> element";
21 case LLM_ERR_CONFIG_PARSE: return "Config: XML parse error";
22 case LLM_ERR_CONFIG_NO_NAME: return "Config: missing name attribute";
23 case LLM_ERR_CONFIG_NO_VENDOR: return "Config: missing <llmvendor>";
24 case LLM_ERR_CONFIG_UNKNOWN_VENDOR: return "Config: unknown vendor type";
25 case LLM_ERR_CONFIG_NO_ENDPOINT: return "Config: missing endpoint";
26 case LLM_ERR_CONFIG_INLINE_SECRET: return "Config: inline apikey not allowed, use apikeyref";
27 case LLM_ERR_CONFIG_INVALID: return "Config: invalid";
28 case LLM_ERR_UNKNOWN_MODEL: return "Unknown model";
29 case LLM_ERR_SECRET_UNRESOLVED: return "API key secret unresolved";
30 case LLM_ERR_NO_INPUT: return "Empty input";
31 case LLM_ERR_HTTP_UNREACHABLE: return "HTTP: endpoint unreachable";
32 case LLM_ERR_HTTP: return "HTTP: non-200 reply";
33 case LLM_ERR_REPLY_PARSE: return "Reply parse error";
34 case LLM_ERR_CONFIG_BAD_TRANSPORT: return "Config: unknown stream transport";
35 case LLM_ERR_STREAM_CANCELLED: return "Stream cancelled by caller";
36 }
37 return "Unknown result";
38}
39
41 switch (type) {
42 case LLM_VENDOR_NONE: return "none";
43 case LLM_VENDOR_OPENAI: return "openai";
44 case LLM_VENDOR_ANTHROPIC: return "anthropic";
45 case LLM_VENDOR_BEDROCK: return "bedrock";
46 case LLM_VENDOR_GOOGLE: return "google";
47 case LLM_VENDOR_CUSTOM: return "custom";
48 }
49 return "none";
50}
51
53 switch (transport) {
54 case LLM_STREAM_NONE: return "none";
55 case LLM_STREAM_SSE: return "sse";
56 case LLM_STREAM_EVENTSTREAM: return "eventstream";
57 case LLM_STREAM_CHUNKED: return "chunked";
58 }
59 return "none";
60}
61
63 configured(false), connected(false), lastError(LLM_OK),
64 vendorType(LLM_VENDOR_NONE), streamTransport(LLM_STREAM_NONE),
65 globalConnection(false), owner(NULL), ownerEndCallback(NULL),
66 ownerNotified(false), cancelRequested(false), connectTimeMS(0),
67 netManager(NULL) {
68}
69
71 if (connected) {
72 currentStats.uptimeMS = GetTimeNow() - connectTimeMS;
73 currentStats.ended = true;
74 }
75 notifyOwnerEnded();
76 delete netManager;
77}
78
79void LLMConnection::setOwner(void* newOwner, LLMOwnerEndCallback callback) {
80 owner = newOwner;
81 ownerEndCallback = callback;
82 ownerNotified = false;
83}
84
85void LLMConnection::notifyOwnerEnded() {
86 if (ownerEndCallback && !ownerNotified) {
87 ownerNotified = true;
88 ownerEndCallback(owner, this, currentStats);
89 }
90}
91
92LLMResult LLMConnection::fail(LLMResult result) {
93 configured = false;
94 lastError = result;
95 return result;
96}
97
98void LLMConnection::clearConfig() {
99 configured = false;
100 connected = false;
101 lastError = LLM_OK;
102 name.clear();
103 vendorType = LLM_VENDOR_NONE;
104 vendorTypeName.clear();
105 endpoint.clear();
106 streamTransport = LLM_STREAM_NONE;
107 globalConnection = false;
108 schemaVersion.clear();
109 apiKeyRef.clear();
110 secretsFile.clear();
111 apiKeySecret.clear();
112 requestTemplate.clear();
113 responseTemplate.clear();
114 headers.clear();
115 constraints = LLMConstraints();
116 models.clear();
117 selectedModel.clear();
118 currentStats = LLMStats();
119 cancelRequested = false;
120 connectTimeMS = 0;
121 delete netManager;
122 netManager = NULL;
123}
124
125static bool attrTrue(const XMLNode& node, const char* attr) {
126 const char* v = node.getAttribute(attr);
127 return (v && (!stricmp(v, "true") || !stricmp(v, "yes") || !stricmp(v, "1")));
128}
129
130bool LLMConnection::nodeHasInlineSecret(const XMLNode& node) {
131 if (node.isAttributeSet("apikey")) return true;
132 if (node.nChildNode("apikey") > 0) return true;
133 return false;
134}
135
136void LLMConnection::resolveSecret() {
137 apiKeySecret.clear();
138 if (apiKeyRef.empty()) return;
139 // 1.3a (Q-C): explicit indirect references.
140 // env:NAME -> environment variable NAME (must be set and non-empty)
141 // file:/path -> whole file contents, trailing whitespace/newlines trimmed
142 // NAME (bare) -> legacy: env var NAME, else key=value lookup in `secretsfile`
143 // The secret value itself is never logged or included in any error text;
144 // failure surfaces only as the typed LLM_ERR_SECRET_UNRESOLVED at connect().
145 if (!strnicmp(apiKeyRef.c_str(), "env:", 4)) {
146 const char* env = getenv(apiKeyRef.c_str() + 4);
147 if (env && *env) apiKeySecret = env;
148 return;
149 }
150 if (!strnicmp(apiKeyRef.c_str(), "file:", 5)) {
151 FILE* f = fopen(apiKeyRef.c_str() + 5, "rb");
152 if (!f) return;
153 std::string content;
154 char buf[4096];
155 size_t n;
156 while ((n = fread(buf, 1, sizeof(buf), f)) > 0) content.append(buf, n);
157 fclose(f);
158 while (!content.empty() && (content[content.size()-1] == '\n' ||
159 content[content.size()-1] == '\r' || content[content.size()-1] == ' ' ||
160 content[content.size()-1] == '\t'))
161 content.erase(content.size()-1);
162 apiKeySecret = content;
163 return;
164 }
165 const char* env = getenv(apiKeyRef.c_str());
166 if (env && *env) { apiKeySecret = env; return; }
167 if (secretsFile.empty()) return;
168 FILE* f = fopen(secretsFile.c_str(), "r");
169 if (!f) return;
170 char line[2048];
171 while (fgets(line, sizeof(line), f)) {
172 char* eq = strchr(line, '=');
173 if (!eq) continue;
174 *eq = 0;
175 // trim trailing whitespace/newline from value
176 char* val = eq + 1;
177 size_t len = strlen(val);
178 while (len && (val[len-1] == '\n' || val[len-1] == '\r' || val[len-1] == ' ' || val[len-1] == '\t'))
179 val[--len] = 0;
180 if (!stricmp(line, apiKeyRef.c_str()) && len) {
181 apiKeySecret = val;
182 break;
183 }
184 }
185 fclose(f);
186}
187
189 if (!xml || !*xml) return fail(LLM_ERR_CONFIG_EMPTY);
190 XMLResults results;
191 XMLNode node = XMLNode::parseString(xml, "llm", &results);
192 if (results.error != eXMLErrorNone || node.isEmpty())
193 return fail(LLM_ERR_CONFIG_PARSE);
194 return configure(node);
195}
196
198 clearConfig();
199 if (llmNode.isEmpty()) return fail(LLM_ERR_CONFIG_EMPTY);
200 const char* nodeName = llmNode.getName();
201 if (!nodeName || stricmp(nodeName, "llm")) return fail(LLM_ERR_CONFIG_NOT_LLM);
202 if (nodeHasInlineSecret(llmNode)) return fail(LLM_ERR_CONFIG_INLINE_SECRET);
203 const char* v = llmNode.getAttribute("name");
204 if (!v || !*v) return fail(LLM_ERR_CONFIG_NO_NAME);
205 name = v;
206 globalConnection = attrTrue(llmNode, "global");
207 if ((v = llmNode.getAttribute("schemaversion")) != NULL) schemaVersion = v;
208 if ((v = llmNode.getAttribute("apikeyref")) != NULL) apiKeyRef = v;
209 if ((v = llmNode.getAttribute("secretsfile")) != NULL) secretsFile = v;
210 if ((v = llmNode.getAttribute("maxtokensperrequest")) != NULL) constraints.maxTokensPerRequest = atoi(v);
211 if ((v = llmNode.getAttribute("maxrequestspermin")) != NULL) constraints.maxRequestsPerMin = atoi(v);
212 if ((v = llmNode.getAttribute("maxconcurrent")) != NULL) constraints.maxConcurrent = atoi(v);
213 if ((v = llmNode.getAttribute("costceiling")) != NULL) constraints.costCeiling = atof(v);
214 if (llmNode.nChildNode("llmvendor") < 1) return fail(LLM_ERR_CONFIG_NO_VENDOR);
215 LLMResult res = parseVendor(llmNode.getChildNode("llmvendor"));
216 if (res != LLM_OK) return fail(res);
217 resolveSecret();
218 configured = true;
219 lastError = LLM_OK;
220 return LLM_OK;
221}
222
223LLMResult LLMConnection::parseVendor(const XMLNode& vendorNode) {
224 if (vendorNode.isEmpty()) return LLM_ERR_CONFIG_NO_VENDOR;
225 if (nodeHasInlineSecret(vendorNode)) return LLM_ERR_CONFIG_INLINE_SECRET;
226 const char* v = vendorNode.getAttribute("type");
227 if (!v || !*v) return LLM_ERR_CONFIG_UNKNOWN_VENDOR;
228 vendorTypeName = v;
229 if (!stricmp(v, "openai")) vendorType = LLM_VENDOR_OPENAI;
230 else if (!stricmp(v, "anthropic")) vendorType = LLM_VENDOR_ANTHROPIC;
231 else if (!stricmp(v, "bedrock")) vendorType = LLM_VENDOR_BEDROCK;
232 else if (!stricmp(v, "google")) vendorType = LLM_VENDOR_GOOGLE;
233 else if (!stricmp(v, "custom")) vendorType = LLM_VENDOR_CUSTOM;
235 v = vendorNode.getAttribute("endpoint");
236 if (!v || !*v) return LLM_ERR_CONFIG_NO_ENDPOINT;
237 endpoint = v;
238 LLMResult tres = resolveStreamTransport(vendorNode.getAttribute("transport"));
239 if (tres != LLM_OK) return tres;
240 int n = vendorNode.nChildNode("header");
241 for (int i = 0; i < n; i++) {
242 XMLNode h = vendorNode.getChildNode("header", i);
243 LLMHeader header;
244 const char* hn = h.getAttribute("name");
245 const char* hv = h.getAttribute("value");
246 if (!hn || !*hn) return LLM_ERR_CONFIG_INVALID;
247 header.name = hn;
248 if (hv) header.value = hv;
249 headers.push_back(header);
250 }
251 XMLNode t = vendorNode.getChildNode("requesttemplate");
252 if (!t.isEmpty() && t.getText()) requestTemplate = t.getText();
253 t = vendorNode.getChildNode("responsetemplate");
254 if (!t.isEmpty() && t.getText()) responseTemplate = t.getText();
255 XMLNode modelsNode = vendorNode.getChildNode("models");
256 if (!modelsNode.isEmpty()) {
257 n = modelsNode.nChildNode("model");
258 for (int i = 0; i < n; i++) {
259 const char* mn = modelsNode.getChildNode("model", i).getAttribute("name");
260 if (!mn || !*mn) return LLM_ERR_CONFIG_INVALID;
261 models.push_back(mn);
262 }
263 const char* def = modelsNode.getAttribute("default");
264 if (def && *def) {
265 bool found = false;
266 for (size_t i = 0; i < models.size(); i++)
267 if (!stricmp(models[i].c_str(), def)) { selectedModel = models[i]; found = true; break; }
268 if (!found) return LLM_ERR_UNKNOWN_MODEL;
269 }
270 else if (!models.empty()) selectedModel = models[0];
271 }
272 return LLM_OK;
273}
274
275std::vector<std::string> LLMConnection::listModels() const {
276 return models;
277}
278
279LLMResult LLMConnection::selectModel(const char* modelName) {
280 if (!configured) { lastError = LLM_ERR_NOT_CONFIGURED; return LLM_ERR_NOT_CONFIGURED; }
281 if (modelName && *modelName) {
282 for (size_t i = 0; i < models.size(); i++) {
283 if (!stricmp(models[i].c_str(), modelName)) {
284 selectedModel = models[i];
285 lastError = LLM_OK;
286 return LLM_OK;
287 }
288 }
289 }
290 lastError = LLM_ERR_UNKNOWN_MODEL;
292}
293
294// Escape a plain string for embedding inside a JSON string literal.
295static std::string llmJSONEscape(const char* s) {
296 std::string out;
297 if (!s) return out;
298 for (const char* p = s; *p; p++) {
299 unsigned char c = (unsigned char)*p;
300 switch (c) {
301 case '"': out += "\\\""; break;
302 case '\\': out += "\\\\"; break;
303 case '\b': out += "\\b"; break;
304 case '\f': out += "\\f"; break;
305 case '\n': out += "\\n"; break;
306 case '\r': out += "\\r"; break;
307 case '\t': out += "\\t"; break;
308 default:
309 if (c < 0x20) {
310 char buf[8];
311 snprintf(buf, sizeof(buf), "\\u%04x", c);
312 out += buf;
313 }
314 else out += (char)c;
315 }
316 }
317 return out;
318}
319
320static void llmReplaceAll(std::string& text, const char* what, const std::string& with) {
321 size_t wlen = strlen(what), pos = 0;
322 while ((pos = text.find(what, pos)) != std::string::npos) {
323 text.replace(pos, wlen, with);
324 pos += with.length();
325 }
326}
327
328// Substitute %apikey%, %model% and %input% (input JSON-escaped) in `text`.
329std::string LLMConnection::applyTemplates(const std::string& text, const char* input) const {
330 std::string out = text;
331 llmReplaceAll(out, "%apikey%", apiKeySecret);
332 llmReplaceAll(out, "%model%", selectedModel);
333 if (input) llmReplaceAll(out, "%input%", llmJSONEscape(input));
334 return out;
335}
336
337// Return the index just past token i's whole subtree (DFS order).
338static int llmSkipToken(const jsmntok_t* t, int i, int count) {
339 int end = t[i].end;
340 i++;
341 while (i < count && t[i].start >= 0 && t[i].start < end) i++;
342 return i;
343}
344
345// Walk a dotted/indexed path (e.g. choices[0].message.content) through the
346// jsmn token stream; `out` gets the raw token text (strings unescaped by jsmn
347// bounds only — quotes excluded). Returns false when the path does not match.
348bool LLMConnection::extractJSONPath(const char* json, uint32 size, const char* path,
349 std::string& out) {
350 if (!json || !size || !path || !*path) return false;
351 jsmn_parser parser;
352 jsmn_init(&parser);
353 int count = jsmn_parse(&parser, json, size, NULL, 0);
354 if (count <= 0) return false;
355 std::vector<jsmntok_t> tokens((size_t)count);
356 jsmn_init(&parser);
357 if (jsmn_parse(&parser, json, size, &tokens[0], (unsigned int)count) != count)
358 return false;
359 jsmntok_t* t = &tokens[0];
360 int cur = 0;
361 const char* p = path;
362 while (*p) {
363 if (*p == '.') { p++; continue; }
364 if (*p == '[') { // array index segment
365 int idx = (int)strtol(p + 1, (char**)&p, 10);
366 if (*p != ']') return false;
367 p++;
368 if (t[cur].type != JSMN_ARRAY || idx < 0 || idx >= t[cur].size) return false;
369 int child = cur + 1;
370 for (int n = 0; n < idx; n++) child = llmSkipToken(t, child, count);
371 cur = child;
372 continue;
373 }
374 const char* segStart = p; // object key segment
375 while (*p && *p != '.' && *p != '[') p++;
376 std::string key(segStart, (size_t)(p - segStart));
377 if (t[cur].type != JSMN_OBJECT) return false;
378 int child = cur + 1;
379 bool found = false;
380 for (int n = 0; n < t[cur].size && child < count; n++) {
381 int keyLen = t[child].end - t[child].start;
382 if (t[child].type == JSMN_STRING && (int)key.length() == keyLen &&
383 strncmp(json + t[child].start, key.c_str(), (size_t)keyLen) == 0) {
384 cur = child + 1;
385 found = true;
386 break;
387 }
388 child = llmSkipToken(t, child + 1, count); // skip key's value subtree
389 }
390 if (!found) return false;
391 }
392 out.assign(json + t[cur].start, (size_t)(t[cur].end - t[cur].start));
393 return true;
394}
395
397 if (!configured) return LLM_ERR_NOT_CONFIGURED;
398 if (!apiKeyRef.empty() && apiKeySecret.empty()) {
399 lastError = LLM_ERR_SECRET_UNRESOLVED;
401 }
402 if (!netManager) netManager = new NetworkManager();
403 connected = true;
404 connectTimeMS = GetTimeNow();
405 currentStats = LLMStats(); // fresh stats record per connect()
406 ownerNotified = false; // 1.2d-i: re-arm owner-end notification
407 lastError = LLM_OK;
408 return LLM_OK;
409}
410
412 if (!configured) return LLM_ERR_NOT_CONFIGURED;
413 cancelRequested = true; // aborts any in-flight interactStream() promptly
414 if (connected) {
415 currentStats.uptimeMS = GetTimeNow() - connectTimeMS;
416 currentStats.ended = true; // stats record retained, marked ended
417 }
418 connected = false;
419 connectTimeMS = 0;
420 delete netManager;
421 netManager = NULL;
422 lastError = LLM_OK;
423 notifyOwnerEnded(); // 1.2d-i: owning Node retains the ended stats snapshot
424 return LLM_OK;
425}
426
427// Unescape common JSON string escapes in extracted reply text.
428static std::string llmJSONUnescape(const std::string& in) {
429 std::string out;
430 out.reserve(in.length());
431 for (size_t i = 0; i < in.length(); i++) {
432 if (in[i] == '\\' && i + 1 < in.length()) {
433 char c = in[++i];
434 switch (c) {
435 case 'n': out += '\n'; break;
436 case 't': out += '\t'; break;
437 case 'r': out += '\r'; break;
438 case 'b': out += '\b'; break;
439 case 'f': out += '\f'; break;
440 case 'u':
441 if (i + 4 < in.length()) { // basic BMP codepoint -> UTF-8
442 unsigned int cp = (unsigned int)strtoul(in.substr(i + 1, 4).c_str(), NULL, 16);
443 i += 4;
444 if (cp < 0x80) out += (char)cp;
445 else if (cp < 0x800) {
446 out += (char)(0xC0 | (cp >> 6));
447 out += (char)(0x80 | (cp & 0x3F));
448 }
449 else {
450 out += (char)(0xE0 | (cp >> 12));
451 out += (char)(0x80 | ((cp >> 6) & 0x3F));
452 out += (char)(0x80 | (cp & 0x3F));
453 }
454 }
455 break;
456 default: out += c; break; // covers \" \\ \/
457 }
458 }
459 else out += in[i];
460 }
461 return out;
462}
463
464// Fold vendor usage token counts (OpenAI/Anthropic/Bedrock field names)
465// from a reply or stream-event JSON body into currentStats. Events without
466// usage fields are a silent no-op.
467void LLMConnection::accountUsageJSON(const char* json, uint32 size) {
468 if (!json || !size) return;
469 std::string tok;
470 if (extractJSONPath(json, size, "usage.prompt_tokens", tok) ||
471 extractJSONPath(json, size, "usage.input_tokens", tok) ||
472 extractJSONPath(json, size, "usage.inputTokens", tok))
473 currentStats.tokensIn += (uint64)strtoull(tok.c_str(), NULL, 10);
474 if (extractJSONPath(json, size, "usage.completion_tokens", tok) ||
475 extractJSONPath(json, size, "usage.output_tokens", tok) ||
476 extractJSONPath(json, size, "usage.outputTokens", tok))
477 currentStats.tokensOut += (uint64)strtoull(tok.c_str(), NULL, 10);
478}
479
480// Record a runtime (non-configuration) error without unconfiguring.
481LLMResult LLMConnection::interactFail(LLMResult result) {
482 lastError = result;
483 return result;
484}
485
486// Resolve the streaming wire format: explicit <llmvendor transport="...">
487// wins; otherwise default by vendor (bedrock -> eventstream, else sse).
488LLMResult LLMConnection::resolveStreamTransport(const char* transportAttr) {
489 if (transportAttr && *transportAttr) {
490 if (!stricmp(transportAttr, "sse")) streamTransport = LLM_STREAM_SSE;
491 else if (!stricmp(transportAttr, "eventstream")) streamTransport = LLM_STREAM_EVENTSTREAM;
492 else if (!stricmp(transportAttr, "chunked")) streamTransport = LLM_STREAM_CHUNKED;
494 return LLM_OK;
495 }
496 streamTransport = (vendorType == LLM_VENDOR_BEDROCK)
498 return LLM_OK;
499}
500
501// Byte source that yields a prefix buffer (bytes read past the HTTP header)
502// before delegating to the live connection source, and turns cancellation
503// (callback returned false) into an immediate SRC_ERROR so the bounded pump
504// loops exit promptly.
505namespace {
506class LLMPrefixByteSource : public cmsdk::StreamByteSource {
507public:
508 LLMPrefixByteSource(const std::string& prefixBytes,
509 cmsdk::StreamByteSource& nextSource, const bool& cancelledFlag,
510 const volatile bool& resetFlag, uint64& bodyByteCounter)
511 : prefix(prefixBytes), pos(0), next(nextSource), cancelled(cancelledFlag),
512 resetRequested(resetFlag), bodyBytes(bodyByteCounter) {}
513 cmsdk::StreamReadResult read(char* buf, size_t maxSize, size_t& got) {
514 if (cancelled || resetRequested) return cmsdk::SRC_ERROR;
515 if (pos < prefix.size()) {
516 got = prefix.size() - pos;
517 if (got > maxSize) got = maxSize;
518 memcpy(buf, prefix.data() + pos, got);
519 pos += got;
520 return cmsdk::SRC_DATA;
521 }
522 cmsdk::StreamReadResult r = next.read(buf, maxSize, got);
523 if (r == cmsdk::SRC_DATA)
524 bodyBytes += (uint64)got; // streamed body bytes past the header block
525 return r;
526 }
527private:
528 std::string prefix;
529 size_t pos;
530 cmsdk::StreamByteSource& next;
531 const bool& cancelled;
532 const volatile bool& resetRequested;
533 uint64& bodyBytes;
534};
535} // anonymous namespace
536
537// Reads the HTTP reply status line + headers off the live connection, then
538// pumps the remaining body bytes through the decoder selected at configure()
539// time, delivering incremental token text to the callback. Per-call state
540// only; the connection is owned (and closed) by interactStream().
541LLMResult LLMConnection::pumpStreamReply(void* conPtr,
542 LLMStreamCallback callback, void* userData, std::string* fullReply) {
543 NetworkConnection* con = (NetworkConnection*)conPtr;
544 cmsdk::ConnectionByteSource<NetworkConnection> raw(*con, 250);
545
546 // 1. Accumulate the HTTP header block (status line + headers + CRLFCRLF).
547 std::string head;
548 size_t headerEnd = std::string::npos;
549 char buf[4096];
550 for (size_t iter = 0; iter < 1000 && headerEnd == std::string::npos; iter++) {
551 if (cancelRequested) return LLM_ERR_STREAM_CANCELLED;
552 size_t got = 0;
553 cmsdk::StreamReadResult r = raw.read(buf, sizeof(buf), got);
554 if (r == cmsdk::SRC_ERROR || r == cmsdk::SRC_CLOSED)
556 if (r == cmsdk::SRC_DATA) {
557 head.append(buf, got);
558 if (head.size() > 65536) return LLM_ERR_REPLY_PARSE;
559 headerEnd = head.find("\r\n\r\n");
560 }
561 }
562 if (headerEnd == std::string::npos) return LLM_ERR_HTTP_UNREACHABLE;
563 currentStats.bytesReceived += (uint64)head.size();
564
565 // 2. Status code from "HTTP/1.x NNN ...".
566 size_t sp = head.find(' ');
567 int status = (sp != std::string::npos) ? atoi(head.c_str() + sp + 1) : 0;
568 if (status != 200) return LLM_ERR_HTTP;
569
570 // 3. Body bytes already read past the header become the pump prefix.
571 std::string leftover = head.substr(headerEnd + 4);
572 bool cancelled = false;
573 LLMPrefixByteSource src(leftover, raw, cancelled, cancelRequested,
574 currentStats.bytesReceived);
575
576 // Deliver one decoded token: append to fullReply, hand to the callback;
577 // callback == false flips `cancelled`, which stops the byte source.
578 auto deliver = [&](const char* text, size_t size) {
579 if (cancelled || cancelRequested || !size) return;
580 currentStats.replyChunks++;
581 if (fullReply) fullReply->append(text, size);
582 if (!callback(text, (uint32)size, userData)) cancelled = true;
583 };
584 // Token text from one SSE event / eventstream frame payload: extract via
585 // the configured responsetemplate JSON path (mode-A convention); events
586 // that do not match the path (metadata, usage, stops) are skipped —
587 // except usage token counts, folded into stats exactly like mode A.
588 auto deliverJSON = [&](const std::string& json) {
589 accountUsageJSON(json.data(), (uint32)json.size());
590 std::string extracted;
591 if (extractJSONPath(json.data(), (uint32)json.size(),
592 responseTemplate.c_str(), extracted)) {
593 std::string text = llmJSONUnescape(extracted);
594 deliver(text.data(), text.size());
595 }
596 };
597
598 // 4. Drive the transport decoder (bounded pumps; local decoder state).
600 switch (streamTransport) {
601 case LLM_STREAM_SSE: {
602 cmsdk::SSEEventParser parser;
603 pr = cmsdk::pumpSSEStream(src, parser,
604 [&](const std::string& ev) { deliverJSON(ev); });
605 break;
606 }
608 cmsdk::EventStreamFrameParser parser;
609 pr = cmsdk::pumpEventStream(src, parser,
610 [&](const cmsdk::EventStreamFrameParser::Frame& f) {
611 deliverJSON(f.payload);
612 });
613 break;
614 }
615 case LLM_STREAM_CHUNKED: {
616 cmsdk::HTTPChunkedDecoder dec;
617 pr = cmsdk::pumpChunkedStream(src, dec,
618 [&](const char* data, size_t size) { deliver(data, size); });
619 break;
620 }
621 default:
623 }
624
625 // 5. Map pump outcome onto LLMResult (cancellation wins).
626 if (cancelled || cancelRequested) return LLM_ERR_STREAM_CANCELLED;
627 if (pr == cmsdk::PUMP_COMPLETE) return LLM_OK;
628 if (pr == cmsdk::PUMP_CLOSED) // no [DONE]/0-chunk: normal for plain SSE
629 return (streamTransport == LLM_STREAM_SSE) ? LLM_OK : LLM_ERR_REPLY_PARSE;
630 return LLM_ERR_REPLY_PARSE; // PUMP_ERROR / PUMP_LIMIT
631}
632
633// Mode-B streaming interact (T1.1 step 1.2c-ii): open a dedicated outbound
634// connection to the configured endpoint, send the mode-A-shaped request, and
635// drive the transport decoder selected at configure() time, delivering
636// incremental token text to the callback. All per-call state (connection,
637// decoders, buffers) is local; nothing streams through NetworkManager (its
638// HTTP path is blocking whole-reply).
640 LLMStreamCallback callback, void* userData, std::string* fullReply) {
641 if (fullReply) fullReply->clear();
642 if (!configured) return interactFail(LLM_ERR_NOT_CONFIGURED);
643 if (!connected) return interactFail(LLM_ERR_NOT_CONNECTED);
644 if (!input || !*input) return interactFail(LLM_ERR_NO_INPUT);
645 if (!callback) return interactFail(LLM_ERR_CONFIG_INVALID);
646 if (streamTransport == LLM_STREAM_NONE)
647 return interactFail(LLM_ERR_CONFIG_BAD_TRANSPORT);
648 cancelRequested = false; // fresh stream; a reset() from here on cancels it
649
650 // URL split + encryption choice, matching NetworkManager::makeHTTPRequest.
651 std::string protocol = html::GetProtocolFromURL(endpoint);
652 std::string host = html::GetHostFromURL(endpoint);
653 uint16 port = html::GetPortFromURL(endpoint);
654 bool useSSL = (stricmp(protocol.c_str(), "https") == 0);
655 if (!useSSL && stricmp(protocol.c_str(), "http"))
656 return interactFail(LLM_ERR_HTTP_UNREACHABLE);
657 if (!host.length()) return interactFail(LLM_ERR_HTTP_UNREACHABLE);
658 if (!port) port = useSSL ? 443 : 80;
659 std::string uri = html::GetURIFromURL(endpoint);
660 if (!uri.length()) uri = "/";
661
662 // Shape request body and headers exactly like mode-A interact().
663 std::string body = applyTemplates(requestTemplate, input);
664 std::map<std::string, std::string> headerEntries;
665 for (size_t i = 0; i < headers.size(); i++)
666 headerEntries[headers[i].name] = applyTemplates(headers[i].value, NULL);
667
668 currentStats.requestCount++;
669 currentStats.bytesSent += (uint64)body.length();
670
671 // Dedicated per-call socket (plain or SSL), not owned by netManager.
672 SSLConnection sslCon;
673 TCPConnection tcpCon;
674 NetworkConnection* con = useSSL
675 ? (NetworkConnection*)&sslCon : (NetworkConnection*)&tcpCon;
676 uint64 location = 0;
677 bool ok = useSSL
678 ? sslCon.connect(host.c_str(), port, location, 30000)
679 : tcpCon.connect(host.c_str(), port, location, 30000);
680 if (!ok) return interactFail(LLM_ERR_HTTP_UNREACHABLE);
681
682 HTTPRequest req;
683 req.createRequest(HTTP_POST, host.c_str(), uri.c_str(), headerEntries,
684 body.c_str(), "application/json", (uint32)body.length(), false, 0);
685 if (!HTTPProtocol::SendHTTPRequest(con, &req)) {
686 con->disconnect();
687 return interactFail(LLM_ERR_HTTP_UNREACHABLE);
688 }
689
690 LLMResult res = pumpStreamReply(con, callback, userData, fullReply);
691 con->disconnect();
692 if (res == LLM_OK) lastError = LLM_OK;
693 return (res == LLM_OK) ? LLM_OK : interactFail(res);
694}
695
696LLMResult LLMConnection::interact(const char* input, std::string& reply) {
697 reply.clear();
698 if (!configured) return interactFail(LLM_ERR_NOT_CONFIGURED);
699 if (!connected || !netManager) return interactFail(LLM_ERR_NOT_CONNECTED);
700 if (!input || !*input) return interactFail(LLM_ERR_NO_INPUT);
701
702 // Shape request body and headers from the configured templates.
703 std::string body = applyTemplates(requestTemplate, input);
704 std::map<std::string, std::string> headerEntries;
705 for (size_t i = 0; i < headers.size(); i++)
706 headerEntries[headers[i].name] = applyTemplates(headers[i].value, NULL);
707
708 currentStats.requestCount++;
709 currentStats.bytesSent += (uint64)body.length();
710
711 HTTPReply* httpReply = netManager->makeHTTPRequest(HTTP_POST, endpoint, 30000,
712 headerEntries, body.c_str(), "application/json", (uint32)body.length());
713 if (!httpReply) return interactFail(LLM_ERR_HTTP_UNREACHABLE);
714
715 uint32 contentSize = 0;
716 const char* content = httpReply->getContent(contentSize);
717 currentStats.bytesReceived += contentSize;
718 uint8 status = httpReply->type;
719
720 if (status == HTTP_SERVER_UNAVAILABLE || status == HTTP_MALFORMED_URL) {
721 delete httpReply;
722 return interactFail(LLM_ERR_HTTP_UNREACHABLE);
723 }
724 if (status != HTTP_OK) {
725#ifdef PSY_LLM_DEBUG_HTTP
726 printf("LLM DEBUG non-200: status=%u content(%u)='%.300s'\n",
727 (unsigned)status, contentSize, content ? content : "");
728#endif
729 delete httpReply;
730 return interactFail(LLM_ERR_HTTP);
731 }
732 if (!content || !contentSize) {
733 delete httpReply;
734 return interactFail(LLM_ERR_REPLY_PARSE);
735 }
736
737 // Token accounting from vendor usage fields, when present.
738 accountUsageJSON(content, contentSize);
739
740 std::string extracted;
741 if (!extractJSONPath(content, contentSize, responseTemplate.c_str(), extracted)) {
742 delete httpReply;
743 return interactFail(LLM_ERR_REPLY_PARSE);
744 }
745 delete httpReply;
746 reply = llmJSONUnescape(extracted);
747 lastError = LLM_OK;
748 return LLM_OK;
749}
750
752 LLMStats s = currentStats;
753 if (connected && connectTimeMS)
754 s.uptimeMS = GetTimeNow() - connectTimeMS; // live uptime while connected
755 return s;
756}
757
758} // namespace cmlabs
HTML/URL helper utilities: entity encoding/decoding, MIME type lookup and URL component parsing.
Connection/channel management layer: multi-protocol listeners, typed dispatch, HTTP client — and the ...
#define HTTP_OK
200 OK
#define HTTP_MALFORMED_URL
400 Bad Request
#define HTTP_POST
POST.
#define HTTP_SERVER_UNAVAILABLE
500 (backend server unavailable)
#define strnicmp
Definition Standard.h:188
#define stricmp
Definition Utils.h:132
static bool SendHTTPRequest(NetworkConnection *con, HTTPRequest *req)
Serialise and send a request.
A parsed or generated HTTP response.
const char * getContent(uint32 &size)
Get the (decoded) response body.
uint8 type
HTTP_* status id of this reply.
A parsed or generated HTTP request (also used for WebSocket upgrade handshakes).
bool createRequest(uint8 type, const char *host, const char *uri, const char *content, uint32 contentSize, bool keepAlive, uint64 ifModifiedSince)
Build a simple request with optional raw body.
LLMResult interact(const char *input, std::string &reply)
LLMResult configure(const XMLNode &llmNode)
void setOwner(void *owner, LLMOwnerEndCallback callback)
LLMResult selectModel(const char *modelName)
LLMResult configureFromString(const char *xml)
LLMResult interactStream(const char *input, LLMStreamCallback callback, void *userData, std::string *fullReply=NULL)
std::vector< std::string > listModels() const
LLMStats stats() const
Abstract base class for all point-to-point network connections.
virtual bool disconnect(uint16 error=0)
Close the connection and release the socket.
Central owner of all channels, listeners and connections in a process.
SSL/TLS-encrypted TCP connection (OpenSSL) with configurable peer verification.
bool connect(SOCKET s, uint64 localAddr, NetworkDataReceiver *receiver=NULL)
Adopt an already-accepted socket and perform the server-side TLS handshake.
Plain TCP stream connection (client-initiated or accepted from a listener).
bool connect(SOCKET s, uint64 localAddr, NetworkDataReceiver *receiver=NULL)
Adopt an already-connected socket (server side, from a TCPListener).
uint64 GetTimeNow()
Return the current absolute time (µs since year 0) according to the TMC.
Definition PsyTime.cpp:69
Third-party (vendored): jsmn minimalistic JSON tokenizer by Serge Zaitsev (MIT licence),...
@ JSMN_OBJECT
Definition jsmn.h:30
@ JSMN_ARRAY
Definition jsmn.h:31
@ JSMN_STRING
Definition jsmn.h:32
int jsmn_parse(jsmn_parser *parser, const char *js, size_t len, jsmntok_t *tokens, unsigned int num_tokens)
Run JSON parser.
Definition jsmn.cpp:156
void jsmn_init(jsmn_parser *parser)
Create JSON parser over an array of tokens.
Definition jsmn.cpp:311
std::string GetURIFromURL(std::string url)
Extract the URI (path plus query) from a URL.
Definition HTML.cpp:349
std::string GetProtocolFromURL(std::string url)
Extract the protocol/scheme from a URL.
Definition HTML.cpp:334
std::string GetHostFromURL(std::string url)
Extract the host name (or IP literal) from a URL.
Definition HTML.cpp:291
uint16 GetPortFromURL(std::string url)
Extract the port number from a URL.
Definition HTML.cpp:314
static void llmReplaceAll(std::string &text, const char *what, const std::string &with)
static std::string llmJSONUnescape(const std::string &in)
@ LLM_STREAM_EVENTSTREAM
@ LLM_STREAM_NONE
@ LLM_STREAM_CHUNKED
@ LLM_VENDOR_CUSTOM
@ LLM_VENDOR_GOOGLE
@ LLM_VENDOR_OPENAI
@ LLM_VENDOR_NONE
@ LLM_VENDOR_BEDROCK
@ LLM_VENDOR_ANTHROPIC
static std::string llmJSONEscape(const char *s)
const char * LLMResultText(LLMResult result)
struct XMLDLLENTRY cmlabs::XMLNode XMLNode
const char * LLMStreamTransportText(LLMStreamTransport transport)
const char * LLMVendorTypeText(LLMVendorType type)
@ eXMLErrorNone
Definition xml_parser.h:131
static int llmSkipToken(const jsmntok_t *t, int i, int count)
bool(* LLMStreamCallback)(const char *token, uint32 size, void *userData)
void(* LLMOwnerEndCallback)(void *owner, LLMConnection *connection, const LLMStats &finalStats)
static bool attrTrue(const XMLNode &node, const char *attr)
@ LLM_ERR_CONFIG_EMPTY
@ LLM_ERR_CONFIG_NO_VENDOR
@ LLM_ERR_CONFIG_INVALID
@ LLM_ERR_CONFIG_NO_ENDPOINT
@ LLM_ERR_NOT_CONNECTED
@ LLM_ERR_CONFIG_PARSE
@ LLM_ERR_SECRET_UNRESOLVED
@ LLM_ERR_CONFIG_BAD_TRANSPORT
@ LLM_ERR_CONFIG_NOT_LLM
@ LLM_ERR_CONFIG_UNKNOWN_VENDOR
@ LLM_ERR_CONFIG_INLINE_SECRET
@ LLM_ERR_UNKNOWN_MODEL
@ LLM_ERR_NOT_IMPLEMENTED
@ LLM_ERR_NOT_CONFIGURED
@ LLM_ERR_CONFIG_NO_NAME
@ LLM_ERR_HTTP_UNREACHABLE
@ LLM_ERR_REPLY_PARSE
@ LLM_ERR_STREAM_CANCELLED
@ LLM_ERR_NO_INPUT
StreamPumpResult pumpChunkedStream(StreamByteSource &src, HTTPChunkedDecoder &dec, const std::function< void(const char *, size_t)> &onData, size_t maxIterations=100000, size_t readSize=4096)
StreamPumpResult pumpSSEStream(StreamByteSource &src, SSEEventParser &parser, const std::function< void(const std::string &)> &onEvent, size_t maxIterations=100000, size_t readSize=4096)
StreamPumpResult pumpEventStream(StreamByteSource &src, EventStreamFrameParser &parser, const std::function< void(const EventStreamFrameParser::Frame &)> &onFrame, size_t maxIterations=100000, size_t readSize=4096)
XMLCSTR getName() const
XMLAttribute getAttribute(int i=0) const
XMLNode getChildNode(int i=0) const
int nChildNode(XMLCSTR name) const
char isEmpty() const
enum XMLError error
Definition xml_parser.h:168
JSON parser.
Definition jsmn.h:65
JSON token description.
Definition jsmn.h:51
int start
Definition jsmn.h:53
int size
Definition jsmn.h:55
int end
Definition jsmn.h:54