CMLabs · Psyclone AIOS

16. LLM Connections

Named, node-owned connections from CMSDK to LLM endpoints: the <llm>/<llmvendor> PsySpec elements, safe credential references, blocking and streaming interaction modes, and the C++ API.

1. What an LLM Connection is

An LLM Connection (shipped in CMSDK 2.2.0) is a named, node-owned connection object to an LLM endpoint. It is configured from the reserved <llm> named-connection XML element, which contains an <llmvendor> vendor-descriptor sub-block describing the HTTP shape of the vendor's API. Once configured and connected, the connection offers two interaction modes: a blocking request/reply exchange (interact()) and a streaming exchange (interactStream()) that delivers decoded tokens incrementally.

C++ only. LLM Connections are a CMSDK C++ facility. There is no Python binding in 2.2.0 — the SWIG interface does not expose LLMConnection. If you need LLM access from a Python module, route it through a C++ component.

Recognised vendor families for <llmvendor type="…"> are openai, anthropic, bedrock, google and custom. The custom type is for config-only vendors that fully specify their own HTTP shape via templates and headers.

2. The <llm> and <llmvendor> elements

<llm name="main" apikeyref="OPENAI_API_KEY" global="true" schemaversion="1"
     maxtokensperrequest="4096" maxrequestspermin="60"
     maxconcurrent="2" costceiling="10.0">
  <llmvendor type="openai" endpoint="https://api.openai.com/v1/chat/completions">
    <header name="Authorization" value="Bearer %apikey%"/>
    <requesttemplate>{"model":"%model%","messages":[...]}</requesttemplate>
    <responsetemplate>choices[0].message.content</responsetemplate>
    <models default="gpt-4o">
      <model name="gpt-4o"/>
      <model name="gpt-4o-mini"/>
    </models>
  </llmvendor>
</llm>

<llm> attributes

AttributeMeaning
nameRequired. <llm> is a named connection; a missing name is the typed error LLM_ERR_CONFIG_NO_NAME.
apikeyrefIndirect credential reference — see section 3. Secrets are never inline.
globalMarks the connection global (isGlobal()).
schemaversionSchema version string for the element.
maxtokensperrequestConstraint: max tokens per request (parsed and stored only in 2.2.0).
maxrequestsperminConstraint: max requests per minute (parsed and stored only).
maxconcurrentConstraint: max concurrent requests (parsed and stored only).
costceilingOptional cost ceiling (parsed and stored only).

<llmvendor> attributes and children

ItemMeaning
typeVendor family: openai, anthropic, bedrock, google, custom. Unrecognised values fail with LLM_ERR_CONFIG_UNKNOWN_VENDOR.
endpointRequired endpoint URL; missing gives LLM_ERR_CONFIG_NO_ENDPOINT.
transportOptional streaming wire format: sse, eventstream or chunked. Resolved once at configure(); when absent, the vendor default applies (bedrockeventstream, everything else → sse). Unrecognised values fail with LLM_ERR_CONFIG_BAD_TRANSPORT.
<header>One configured HTTP header; the value may contain %apikey%/%model% templates.
<requesttemplate>Request body template; %model% and %input% are substituted per request.
<responsetemplate>Dotted/indexed JSON path used to extract the reply text, e.g. choices[0].message.content.
<models>/<model>Model list with a default; selectModel() must name a listed model or it fails with LLM_ERR_UNKNOWN_MODEL.

3. Credentials: apikeyref indirection

Secrets are NEVER inline in the XML. An inline apikey attribute or an <apikey> child element is a typed configuration error: LLM_ERR_CONFIG_INLINE_SECRET. PsySpecs are checked in, copied and logged — keys must not live in them, ever.

apikeyref is an indirect reference, resolved at configure() time. Three forms are accepted:

FormResolution
apikeyref="env:NAME"Environment variable NAME, which must be set and non-empty.
apikeyref="file:/path"Whole file contents, trailing newline/whitespace trimmed (single-line key files).
apikeyref="NAME"Legacy bare form: env var NAME, else a key=value entry in the optional secretsfile.

A missing environment variable or unreadable file fails typed at connect() with LLM_ERR_SECRET_UNRESOLVED. The secret value (and the failure detail) never appears in logs, errors, or getSystemStatus(); the public API only exposes hasResolvedSecret(), never the value.

Correct — indirection via the environment:

<llm name="main" apikeyref="env:MY_LLM_KEY">
  <llmvendor type="openai" endpoint="https://api.openai.com/v1/chat/completions">
    <header name="Authorization" value="Bearer %apikey%"/>
  </llmvendor>
</llm>

Incorrect — rejected at configure() with LLM_ERR_CONFIG_INLINE_SECRET:

<!-- WRONG: inline secret. This does not configure; it fails typed. -->
<llm name="main" apikey="PUT-KEY-HERE-THIS-IS-REJECTED">
  <llmvendor type="openai" endpoint="https://api.openai.com/v1/chat/completions"/>
</llm>
Prefer the explicit env: and file: forms in new specs; the bare form exists for legacy compatibility only.

4. Interaction modes

Mode A — blocking request/reply

connect() validates the configuration and the resolved secret, then marks the connection live and starts a fresh stats record. Endpoint reachability is checked lazily, on the first interact() — the underlying HTTP client is connectionless and blocking, so there is nothing to dial eagerly. interact() shapes the request from the <requesttemplate> (%model%/%input%) and the configured headers (%apikey%/%model%), sends it, and extracts the reply text via the <responsetemplate> JSON path. reset() drops the live state but retains the configuration and the final stats record (ended=true).

Mode B — streaming

interactStream() sends one request and delivers incremental decoded tokens (not raw wire bytes) to a callback as they arrive; on success an optional fullReply holds the assembled full text. The call blocks until the stream completes, errors, or the callback cancels by returning false (the call then returns LLM_ERR_STREAM_CANCELLED).

The streaming wire format is resolved once at configure() time — callers never branch on vendor:

TransportWire format
sse (LLM_STREAM_SSE)Chunked transfer + text/event-stream SSE events. Default for all vendors except bedrock.
eventstream (LLM_STREAM_EVENTSTREAM)AWS/Bedrock binary eventstream framing. Default for bedrock.
chunked (LLM_STREAM_CHUNKED)Raw chunked-transfer body, no event framing.

5. Worked example

PsySpec declaration:

<llm name="assistant" apikeyref="env:MY_LLM_KEY" maxtokensperrequest="4096">
  <llmvendor type="anthropic" endpoint="https://api.anthropic.com/v1/messages">
    <header name="x-api-key" value="%apikey%"/>
    <requesttemplate>{"model":"%model%","max_tokens":1024,"messages":[{"role":"user","content":"%input%"}]}</requesttemplate>
    <responsetemplate>content[0].text</responsetemplate>
    <models default="claude-sonnet-4-5">
      <model name="claude-sonnet-4-5"/>
    </models>
  </llmvendor>
</llm>

C++ usage (there is no Python API for LLM Connections):

#include "LLMConnection.h"
using namespace cmlabs;

LLMConnection llm;
if (llm.configureFromString(xml) != LLM_OK) { /* typed error via getLastError() */ }
llm.selectModel("claude-sonnet-4-5");
if (llm.connect() != LLM_OK) { /* e.g. LLM_ERR_SECRET_UNRESOLVED */ }

// Mode A: blocking
std::string reply;
LLMResult res = llm.interact("Summarise the bake status.", reply);

// Mode B: streaming
bool onToken(const char* token, uint32 size, void* userData); // return false to cancel
std::string full;
res = llm.interactStream("Summarise the bake status.", onToken, NULL, &full);

LLMStats s = llm.stats();   // uptimeMS, bytesSent/Received, requestCount,
                            // tokensIn/Out, replyChunks, cost, ended
llm.reset();                // drop live state; stats retained, ended=true
Owner notification. An owning Node can register setOwner(owner, callback); the LLMOwnerEndCallback fires at most once per connect()…end cycle (from reset() or the destructor) with a final LLMStats snapshot.

6. Error handling

Every operation returns a typed LLMResult; bad configuration must never crash. LLMResultText() gives the human-readable name for logs and typed-error reporting, and getLastError() retains the most recent failure.

CodeMeaning
LLM_OKSuccess.
LLM_ERR_NOT_CONFIGUREDAPI used before a successful configure().
LLM_ERR_NOT_CONNECTEDNo live connection.
LLM_ERR_NOT_IMPLEMENTEDBehaviour deferred to a later step.
LLM_ERR_CONFIG_EMPTYMissing/empty <llm> element.
LLM_ERR_CONFIG_NOT_LLMElement is not an <llm> element.
LLM_ERR_CONFIG_PARSEXML string did not parse.
LLM_ERR_CONFIG_NO_NAME<llm> is a named connection: name required.
LLM_ERR_CONFIG_NO_VENDORMissing <llmvendor> sub-block.
LLM_ERR_CONFIG_UNKNOWN_VENDORUnrecognised <llmvendor type="...">.
LLM_ERR_CONFIG_NO_ENDPOINTMissing endpoint URL.
LLM_ERR_CONFIG_INLINE_SECRETInline apikey in XML — use apikeyref.
LLM_ERR_CONFIG_INVALIDOther structural configuration error.
LLM_ERR_UNKNOWN_MODELselectModel() name not in the model list.
LLM_ERR_SECRET_UNRESOLVEDapikeyref set but no secret could be resolved.
LLM_ERR_NO_INPUTinteract() called with empty input.
LLM_ERR_HTTP_UNREACHABLEEndpoint unreachable / no HTTP reply.
LLM_ERR_HTTPEndpoint replied with a non-200 status.
LLM_ERR_REPLY_PARSEReply body missing/malformed vs responsetemplate.
LLM_ERR_CONFIG_BAD_TRANSPORTUnrecognised <llmvendor transport="...">.
LLM_ERR_STREAM_CANCELLEDStreaming interact stopped early by the sink.

7. Honest limits (2.2.0)

What is NOT shipped.
  • Constraint enforcement. The maxtokensperrequest, maxrequestspermin, maxconcurrent and costceiling fields are parsed and stored only — no enforcement logic in this build. Enforcement is planned for a future release; until then, treat them as declared intent and enforce limits in your own code if you depend on them.
  • No Python binding. LLMConnection is not exposed through the SWIG interface; the API is C++ only.
  • The LLM-tier builder is roadmap. An LLM that autonomously writes, compiles and tests module code is not in 2.2.0. Where LLMs meet Builders today, the model is supervised, not autonomous: your code drives the connection and a plain-code Supervisor drives the bake.

8. See also