@countly/ai-sdk-core
Shared transport, data model, cost calculation, and event builders for the Countly AI SDK.
This is the foundation package. You usually don't install it directly — every adapter pulls it in automatically:
@countly/ai-sdk-openai@countly/ai-sdk-anthropic@countly/ai-sdk-mastra@countly/ai-sdk-vercel@countly/ai-sdk-google-genai@countly/ai-sdk-langchain@countly/ai-sdk-cohere@countly/ai-sdk-llamaindex
Core owns the wire contract. Adapters only decide what they can observe; core decides what the wire looks like.
The two rules core enforces
Every field on every one of the four keys obeys one of these:
- RULE A — dimensions are always present on the key that owns them. When
an adapter cannot observe one, core writes an explicit sentinel (
"unknown","not_reported") — never a guess, never an absence. Absence of a dimension therefore only ever means wrong key, which the key already told you. - RULE B — measures are omitted when they were not measured, and travel with an always-present string marker that says why. No booleans on the wire.
The two markers:
| Marker | Values | Governs |
|---|---|---|
usage_state | reported | not_reported | usage_* and every cost_* |
cost_priced | priced | priced_modality_approx | unpriced_model | no_usage | cost_* |
Consequence to design consumers around: a $0 cost is never emitted to mean
"we have no price for this model", and a 0 token count is never emitted to
mean "the provider told us nothing". Those cases omit the measure and set the
marker. Cost keys are present only when cost_priced is priced or
priced_modality_approx.
schema_version = 2
SCHEMA_VERSION is exported and stamped as an integer on every row of every
key, so a consumer can range-query it (a semver string cannot). Branch on it, not
on sdk_version.
What it provides
- Unified data model (
RawExtractionResult) — OpenTelemetry GenAI-aligned, every field optional: an adapter that cannot observe something omits it and core applies the sentinel or the marker - Event builders —
[CLY]_llm_interaction(one row per generation),[CLY]_llm_tool_used,[CLY]_llm_tool_usage_parameter,[CLY]_llm_interaction_feedback. All four carry the same envelope - Token identities, held by construction rather than by trust (below)
- Cost calculation — built-in pricing for 47 models with per-model override, decomposed into disjoint components
- API host classification —
classifyApiHostType/extractApiHost/describeApiHost - HTTP transport — buffered
POST /i, onebatch_ididempotency key per batch generated outside the retry loop, request timeout, bounded buffer,unref'd flush timer, dead-letter with retry-on-recovery,onDropped - Finish reason normalization —
stop | length | tool_calls | content_filter | error | other | not_reported - Provider normalization —
normalizeProviderreduces"openai.responses"to"openai"(the raw id is preserved inprovider_metadata.provider_id) - Error categorization —
rate_limit | context_length | content_filter | timeout | auth_error | api_error
Token identities
Core derives the sub-buckets rather than trusting them, so these hold on the wire regardless of what an adapter computed:
usage_total == usage_input + usage_output
usage_input == usage_input_fresh + usage_cache_read + usage_cache_write + input audio + input image
usage_output == usage_output_text + usage_reasoning + output audio + output image
usage_input is the provider's total input count — inclusive of both
cache read and cache write — so it reconciles with provider dashboards.
usage_input_fresh is the residual. Reasoning is a subset of output, not an
addition to it.
Each sub-bucket is clamped against what the previous ones already claimed, so it
can never exceed its total. A clamp means the adapter broke an identity; in
debug mode core logs it loudly.
Cost is disjoint
CostResult splits into components in which every token is billed exactly
once, so cost_total is their plain sum:
cost_input (fresh input only) + cost_cache_read + cost_cache_write +
cost_output (excluding separately-priced reasoning) + cost_reason
cost_cache_saved is reported alongside as what the cache reads saved against
the fresh input rate — it is not part of the sum.
Two money bugs this fixes: cache-write tokens were billed on top of an
usage_inputthat already contained them (2.25x instead of 1.25x on Anthropic), and the full output count was charged at the output rate with reasoning then added atreason_per_mtok— overstating every Gemini thinking-model call by up to 2x.
Deleted: cost_cached (never had a producer) → use cost_cache_read /
cost_cache_saved.
Identity fields
| Field | Meaning |
|---|---|
event_id | uuid, the only primary key of a row |
run_id | the turn/call identity — the join column for all cross-family scoping |
prompt_id | a foreign key to the parent fact row, not an identity |
generation_index | 0-based position of this generation within the run |
run_latency_total | the turn's end-to-end wall clock (= latency_total for single-call adapters) |
parent_event_key | which row prompt_id points at: "[CLY]_llm_interaction" or "run" |
run_id resolves first-match-wins: raw.run_id → EventContext.run_id →
EventContext.prompt_id (the host's getPromptId()) → a generated uuid.
API host
import { describeApiHost } from "@countly/ai-sdk-core";
const raw = { ...describeApiHost(client.baseURL) };
// → { api_host_type: "gateway", api_host: "ai-gateway.count.ly" }
api_host_type ∈ vendor_direct | azure_openai | gateway | local_infra | unknown. api_host is the hostname only — scheme, credentials, port, path
and query are stripped and never emitted, because a provider path routinely
carries api keys and deployment ids.
Deleted: api_base_url (raw URL — credential-leak risk).
Direct usage
If you're building a custom integration not covered by an existing adapter:
import {
resolveConfig,
createTransport,
buildAllEvents,
generatePromptId,
describeApiHost,
} from "@countly/ai-sdk-core";
const config = resolveConfig({
appKey: "YOUR_APP_KEY",
url: "https://your-countly-server.com",
});
const transport = createTransport(config);
const raw = {
provider: "my-llm",
model: "my-model",
...describeApiHost("https://llm.internal.example.com/v1"),
usage_input: 100, // TOTAL input, including any cache buckets
usage_output: 50, // TOTAL output, including any reasoning
latency_total: 1200,
status: "success" as const,
};
const events = buildAllEvents(raw, config, {
prompt_id: generatePromptId(), // becomes run_id
sdk_adapter: "custom",
});
transport.enqueue(events);
Two things to know when writing an adapter:
- Omit, never fabricate. Leave
model/provider/statusabsent and core writes"unknown". Leavelatency_totalabsent rather than sending0. LeaveRawToolCall.statusabsent when you only saw the model request the tool — core emits"unknown"rather than a fabricated success. saw_generation: falsesuppresses the interaction row entirely. Use it for a trace that contained no generation: tool rows still emit, scoped byrun_id. This is how "a trace with no generation emits nothing" is implemented.
Feedback events
Feedback is not auto-collected — wire it from your UI's thumbs-up/down handler.
Use createFeedbackTracker (also re-exported by every adapter package) with the
id captured via the onPrompt config callback:
import { createFeedbackTracker } from "@countly/ai-sdk-core";
const feedback = createFeedbackTracker(
{ appKey: "YOUR_APP_KEY", url: "https://your-countly-server.com" },
{ sdk_adapter: "openai" }, // stamp which adapter produced the rated interaction
);
// Rate the whole turn (PromptInfo.prompt_id === PromptInfo.run_id):
feedback.track({ prompt_id: info.prompt_id, rating: "thumbs_up", deviceId: userId });
// Rate ONE generation of a multi-step turn:
feedback.track({ prompt_id: info.event_id, run_id: info.run_id, rating: "thumbs_down" });
parent_event_key records which of the two you did, and rating_kind
(binary | score | category | other) is derived so a numeric score can no longer
be averaged into a thumbs-up rate.
For custom integrations that need full control, the lower-level
buildFeedbackEvent + createTransport primitives remain available.
Transport limits
| Config | Default | Why it exists |
|---|---|---|
flushInterval | 10_000 ms | timer is unref'd, so a CLI or serverless handler can still exit |
maxBatchSize | 20 | flushes are serialised; shutdown() loops until the buffer is empty |
maxBufferSize | 10_000 | an observability SDK must not OOM the app it instruments — oldest dropped first |
requestTimeout | 15_000 ms | without it a hung socket blocks flush() and therefore shutdown() forever |
textPreviewLength | 200 (max 8192) | UX knob for previews |
paramValueLength | 1024 (max 8192) | ingest-safety invariant: one multi-MB tool argument dead-letters the whole batch |
onDropped(events, reason) | — | the only way a host learns its counts are under-reported (send_failed | buffer_full) |
transport.stats() returns { buffered, deadLettered, dropped }.
Truncation is visible: truncateText cuts on a code-point boundary (a slice
by code unit split surrogate pairs and produced invalid UTF-8), appends …, and
the row carries a *_state field set to complete or truncated.
timestamp is in milliseconds and hour/dow are UTC, computed once per
turn so every row of a turn is byte-identical. They used to come from the
container's local timezone, which disagreed with the UTC epoch in the same row.
Full documentation
See the Countly AI SDK repository for the complete wire format, the adapter capability matrix, observability levels (0/1/2), per-user attribution via AsyncLocalStorage, privacy controls, and Countly plugin integration (Drill, Funnels, Cohorts, APM, Crash Analytics).
License
MIT