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.
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
| Attribute | Meaning |
|---|---|
name | Required. <llm> is a named connection; a missing name is the typed error LLM_ERR_CONFIG_NO_NAME. |
apikeyref | Indirect credential reference — see section 3. Secrets are never inline. |
global | Marks the connection global (isGlobal()). |
schemaversion | Schema version string for the element. |
maxtokensperrequest | Constraint: max tokens per request (parsed and stored only in 2.2.0). |
maxrequestspermin | Constraint: max requests per minute (parsed and stored only). |
maxconcurrent | Constraint: max concurrent requests (parsed and stored only). |
costceiling | Optional cost ceiling (parsed and stored only). |
<llmvendor> attributes and children
| Item | Meaning |
|---|---|
type | Vendor family: openai, anthropic, bedrock, google, custom. Unrecognised values fail with LLM_ERR_CONFIG_UNKNOWN_VENDOR. |
endpoint | Required endpoint URL; missing gives LLM_ERR_CONFIG_NO_ENDPOINT. |
transport | Optional streaming wire format: sse, eventstream or chunked. Resolved once at configure(); when absent, the vendor default applies (bedrock → eventstream, 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
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:
| Form | Resolution |
|---|---|
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>
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:
| Transport | Wire 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
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.
| Code | Meaning |
|---|---|
LLM_OK | Success. |
LLM_ERR_NOT_CONFIGURED | API used before a successful configure(). |
LLM_ERR_NOT_CONNECTED | No live connection. |
LLM_ERR_NOT_IMPLEMENTED | Behaviour deferred to a later step. |
LLM_ERR_CONFIG_EMPTY | Missing/empty <llm> element. |
LLM_ERR_CONFIG_NOT_LLM | Element is not an <llm> element. |
LLM_ERR_CONFIG_PARSE | XML string did not parse. |
LLM_ERR_CONFIG_NO_NAME | <llm> is a named connection: name required. |
LLM_ERR_CONFIG_NO_VENDOR | Missing <llmvendor> sub-block. |
LLM_ERR_CONFIG_UNKNOWN_VENDOR | Unrecognised <llmvendor type="...">. |
LLM_ERR_CONFIG_NO_ENDPOINT | Missing endpoint URL. |
LLM_ERR_CONFIG_INLINE_SECRET | Inline apikey in XML — use apikeyref. |
LLM_ERR_CONFIG_INVALID | Other structural configuration error. |
LLM_ERR_UNKNOWN_MODEL | selectModel() name not in the model list. |
LLM_ERR_SECRET_UNRESOLVED | apikeyref set but no secret could be resolved. |
LLM_ERR_NO_INPUT | interact() called with empty input. |
LLM_ERR_HTTP_UNREACHABLE | Endpoint unreachable / no HTTP reply. |
LLM_ERR_HTTP | Endpoint replied with a non-200 status. |
LLM_ERR_REPLY_PARSE | Reply body missing/malformed vs responsetemplate. |
LLM_ERR_CONFIG_BAD_TRANSPORT | Unrecognised <llmvendor transport="...">. |
LLM_ERR_STREAM_CANCELLED | Streaming interact stopped early by the sink. |
7. Honest limits (2.2.0)
- Constraint enforcement. The
maxtokensperrequest,maxrequestspermin,maxconcurrentandcostceilingfields 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.
LLMConnectionis 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
- Builders & Supervisors — the bake pipeline an LLM-assisted workflow feeds into.
- System Guide: Security & SSL — transport security and operational secret handling.