@combycode/llm-sdk 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,3 +6,22 @@ import type { JsonSchema } from './tools';
6
6
  * reject schemas without this explicit flag. Safe across all providers.
7
7
  */
8
8
  export declare function ensureAdditionalProperties(schema: JsonSchema): JsonSchema;
9
+ /** Whose strict-mode rules to judge a schema against. */
10
+ export type StrictDialect = 'openai' | 'anthropic';
11
+ /** Can this schema satisfy the provider's strict mode AS WRITTEN?
12
+ *
13
+ * Strict mode is worth defaulting to — it is what makes a provider constrain the
14
+ * tool name and arguments DURING generation rather than checking after. But the
15
+ * two providers constrain different things, and a schema that violates either is
16
+ * rejected with a 400, not quietly degraded:
17
+ *
18
+ * openai every property must appear in `required`, at every nesting level
19
+ * anthropic a set of validation keywords is simply unsupported
20
+ *
21
+ * So strict is requested only where it can be honoured. The alternative — rewriting
22
+ * the schema to fit, promoting optional properties to required-and-nullable — changes
23
+ * what the tool actually receives, and the receiving end is the caller's code. */
24
+ export declare function strictSupport(schema: JsonSchema | undefined, dialect: StrictDialect): {
25
+ ok: boolean;
26
+ reason?: string;
27
+ };
@@ -21,11 +21,11 @@ import type { RequestContext } from '../types/request-context';
21
21
  import type { RateLimiterConfig } from './rate-limiter';
22
22
  import type { QueueConfig } from './request-queue';
23
23
  import { QueueState } from './queue-state';
24
- import type { RetryConfig } from './queue-state-config';
24
+ import type { RetryPolicyOverride } from './queue-state-config';
25
25
  import type { ConnectFn, FetchFn, HttpRequest, HttpResponse, RealtimeConnection, SSEEvent, WsRequest } from './types';
26
26
  export interface QueueSettings {
27
27
  limits?: Partial<RateLimiterConfig>;
28
- retry?: Partial<RetryConfig>;
28
+ retry?: RetryPolicyOverride;
29
29
  queue?: Partial<QueueConfig>;
30
30
  }
31
31
  export interface NetworkEngineConfig {
@@ -39,6 +39,12 @@ export interface NetworkEngineConfig {
39
39
  connect?: ConnectFn;
40
40
  /** Pre-configured per-queue settings. Looked up by queueName at queue creation. */
41
41
  queues?: Record<string, QueueSettings>;
42
+ /** Retry policy inherited by EVERY queue this engine creates.
43
+ *
44
+ * Retry is a cross-cutting setting, not something a caller should thread through each call, so
45
+ * it is configured once (`createEngine({ retry })`) and applies everywhere. Three layers,
46
+ * narrowest wins: `HttpRequest.retry` > `queues[name].retry` > this. */
47
+ retry?: RetryPolicyOverride;
42
48
  }
43
49
  /** Optional context for fetch/fetchStream. RequestContext + per-call overrides. */
44
50
  export interface FetchOptions {
@@ -52,6 +58,8 @@ export declare class NetworkEngine {
52
58
  readonly hooks: HookBus;
53
59
  private readonly fetchFn;
54
60
  private readonly connectFn;
61
+ /** Engine-wide retry policy, inherited by every queue created from here. */
62
+ private readonly defaultRetry;
55
63
  private readonly settings;
56
64
  private readonly queues;
57
65
  constructor(config?: NetworkEngineConfig);
@@ -22,6 +22,14 @@ export interface BackoffConfig {
22
22
  multiplier: number;
23
23
  jitter: number;
24
24
  }
25
+ /** A retry policy stated as a partial override of another one.
26
+ *
27
+ * `Partial<RetryConfig>` only makes the TOP-level keys optional, so it still demands a complete
28
+ * `backoff` object — which makes "override one knob, keep the rest" inexpressible even though the
29
+ * merge has always supported it. Engine-level and queue-level overrides take this instead. */
30
+ export type RetryPolicyOverride = Omit<Partial<RetryConfig>, 'backoff'> & {
31
+ backoff?: Partial<BackoffConfig>;
32
+ };
25
33
  export interface ErrorRetryConfig {
26
34
  retryable?: boolean;
27
35
  maxRetries?: number;
@@ -34,7 +42,7 @@ export interface QueueStateConfig {
34
42
  fetch: FetchFn;
35
43
  hooks: HookBus;
36
44
  limits: RateLimiterConfig;
37
- retry?: Partial<RetryConfig>;
45
+ retry?: RetryPolicyOverride;
38
46
  queue?: Partial<QueueConfig>;
39
47
  }
40
48
  export declare const Priority: {
@@ -43,4 +51,4 @@ export declare const Priority: {
43
51
  readonly BACKGROUND: 2;
44
52
  readonly LOW: 3;
45
53
  };
46
- export declare function mergeRetry(overrides?: Partial<RetryConfig>): RetryConfig;
54
+ export declare function mergeRetry(overrides?: RetryPolicyOverride): RetryConfig;
@@ -69,6 +69,12 @@ export interface TraceContext {
69
69
  sessionId?: string;
70
70
  requestId?: string;
71
71
  callId?: string;
72
+ /** W3C `traceparent` of the span this work runs UNDER, when the caller runs the SDK
73
+ * inside its own trace. It rides with the ids because every span we emit needs it,
74
+ * not only the ones built straight from a caller's RequestContext — an agent run
75
+ * reaches its tool calls and its nested agents through this object, and those were
76
+ * left rooting traces of their own while the LLM spans joined the caller's. */
77
+ traceparent?: string;
72
78
  }
73
79
  /** Point-in-time numeric state of one queue (for metrics / observability). */
74
80
  export interface QueueSnapshot {
@@ -13,6 +13,8 @@ export declare class CostCollector {
13
13
  private budgets;
14
14
  private triggeredThresholds;
15
15
  private _runningTotal;
16
+ /** Models already reported as unpriced, so the warning fires once rather than per call. */
17
+ private warnedUnpriced;
16
18
  private watchedAgents;
17
19
  private unsub;
18
20
  private unsubMedia;
@@ -36,6 +38,16 @@ export declare class CostCollector {
36
38
  import(entries: CostEntry[]): void;
37
39
  private handleMediaGenerated;
38
40
  private handleCompletion;
41
+ /** A total of exactly 0 because the model is not in the catalog reads identically to a
42
+ * total of 0 because the call was free — and it silently under-counts every budget
43
+ * and report built on it. `source: 'unknown'` already records the difference per
44
+ * entry, but nothing aggregated it, so a whole benchmark run once reported $0.00000
45
+ * for a live provider and looked like a free arm.
46
+ *
47
+ * Fires once per provider/model: an unpriced model is a configuration fact, not a
48
+ * per-request event, and repeating it on every call would train the reader to ignore
49
+ * it. Free calls are priced 'calculated' with an explicit note, so they stay silent. */
50
+ private noteIfUnpriced;
39
51
  private checkBudgets;
40
52
  private filterEntries;
41
53
  private groupBy;
@@ -39,4 +39,12 @@ export interface CostSummary {
39
39
  reasoning: number;
40
40
  };
41
41
  entries: number;
42
+ /** How many of `entries` could not be priced — no catalog entry for the model, or no
43
+ * applicable rate. They count as 0 in every field above, so a summary without this
44
+ * number cannot tell "this run was cheap" from "this run was never priced". Free
45
+ * calls are NOT counted here: they are priced, at zero. */
46
+ unpriced: number;
47
+ /** The distinct `provider/model` values behind `unpriced`, so the gap is actionable
48
+ * rather than merely visible. Empty when everything was priced. */
49
+ unpricedModels: string[];
42
50
  }
@@ -21,7 +21,14 @@ export declare class McpResultCache {
21
21
  * different entries. */
22
22
  static key(method: string, params?: unknown): string;
23
23
  get(key: string, now?: number): unknown | undefined;
24
- /** Store only when the server actually asked for it. Returns whether anything was stored. */
24
+ /** Store only when the server actually asked for it. Returns whether anything was stored.
25
+ *
26
+ * A non-positive `ttlMs` is an instruction, not a missing value: the server is saying *do not
27
+ * reuse this*. Any entry already held under that key is dropped, so the next `get` re-fetches.
28
+ * Without the eviction the hint is inert — a server that first said "cache for 60s" and then
29
+ * says "stale now" would keep being answered from the stale entry for the rest of the original
30
+ * TTL. Absent hints are different and must stay different: they carry no instruction, so an
31
+ * existing entry is left alone and pre-2026 servers behave exactly as before. */
25
32
  set(key: string, value: unknown, hints: McpCacheHints | undefined, now?: number): boolean;
26
33
  /** Drop everything — e.g. after a `*_changed` notification says the server moved on. */
27
34
  clear(): void;
@@ -8,6 +8,8 @@ export interface McpToolAdapterOptions {
8
8
  /** Validate `structuredContent` against the tool's `outputSchema`; on mismatch
9
9
  * the tool returns an error string instead of the content. Default false. */
10
10
  validateOutput?: boolean;
11
+ /** Register the tool without declaring it — see `AgentTool.lazy`. */
12
+ lazy?: boolean;
11
13
  }
12
14
  /** Map a `tools/call` result to our tool-result shape: a plain string when the
13
15
  * content is text-only, else a ContentPart[] (images/audio kept as base64).
@@ -14,6 +14,40 @@ export interface JsonRpcResponse {
14
14
  result?: unknown;
15
15
  error?: JsonRpcError;
16
16
  }
17
+ /** Behavioural hints a server publishes about a tool. Advisory and UNVERIFIED — a
18
+ * server asserts them about itself — so they inform a host's UX (confirm before a
19
+ * destructive call) and must never be treated as a security boundary.
20
+ *
21
+ * Open (CONSTITUTION R1): the spec gains hints over time and an unknown one must
22
+ * survive rather than be dropped. */
23
+ export interface McpToolAnnotations {
24
+ title?: string;
25
+ /** The tool does not modify its environment. Default false. */
26
+ readOnlyHint?: boolean;
27
+ /** The tool may perform destructive updates rather than only additive ones. */
28
+ destructiveHint?: boolean;
29
+ /** Repeated calls with the same arguments have no additional effect. */
30
+ idempotentHint?: boolean;
31
+ /** The tool touches an open world (the internet) rather than a closed one. */
32
+ openWorldHint?: boolean;
33
+ [hint: string]: unknown;
34
+ }
35
+ /** An icon a host may render beside the tool. Display only. */
36
+ export interface McpToolIcon {
37
+ src: string;
38
+ mimeType?: string;
39
+ sizes?: string[];
40
+ theme?: 'light' | 'dark' | (string & {});
41
+ }
42
+ /** How the tool must be invoked.
43
+ *
44
+ * `taskSupport: 'required'` means the client MUST call it as a task rather than a
45
+ * plain `tools/call`. Task invocation is not implemented here, so such a tool
46
+ * cannot be called through this client — carrying the field lets a caller SEE
47
+ * that instead of discovering it as a server error. Absent means `'forbidden'`. */
48
+ export interface McpToolExecution {
49
+ taskSupport?: 'required' | 'optional' | 'forbidden' | (string & {});
50
+ }
17
51
  export interface McpToolDef {
18
52
  name: string;
19
53
  description?: string;
@@ -22,6 +56,15 @@ export interface McpToolDef {
22
56
  inputSchema: Record<string, unknown>;
23
57
  /** JSON Schema for the tool's `structuredContent` output (optional). */
24
58
  outputSchema?: Record<string, unknown>;
59
+ /** Advisory behavioural hints. HOST-facing, never sent to the model: no
60
+ * provider's function-tool schema has a field that could carry them. */
61
+ annotations?: McpToolAnnotations;
62
+ /** Display icons for a host UI. */
63
+ icons?: McpToolIcon[];
64
+ /** Invocation requirements; see {@link McpToolExecution}. */
65
+ execution?: McpToolExecution;
66
+ /** Spec-defined passthrough metadata. */
67
+ _meta?: Record<string, unknown>;
25
68
  }
26
69
  /** A content block in a `tools/call` result. Open union — unknown types ignored. */
27
70
  export type McpContentBlock = {
@@ -11,10 +11,29 @@
11
11
  * into OTLP-compatible JSON for a real OTel exporter to forward. */
12
12
  import type { HookBus } from '../../bus/hook-bus';
13
13
  import type { HookName } from '../../bus/hook-map';
14
+ /** Derive a conformant hex id from one of our readable ids. `bytes` is 16 for a trace
15
+ * id, 8 for a span id.
16
+ *
17
+ * Deterministic on purpose: the same logical trace maps to the same OTLP id on every
18
+ * export and in every process, so a trace stitched together from two exports — or
19
+ * from two services sharing a sessionId — still joins up in the backend. */
20
+ export declare function toOtlpId(input: string, bytes: 8 | 16): string;
21
+ /** One attribute in OTLP's AnyValue shape, keeping numbers numeric. Integers go out as
22
+ * `intValue` carrying a STRING, which is how OTLP/JSON encodes 64-bit integers; send
23
+ * them as plain strings instead and no backend can sum them. */
24
+ export declare function toOtlpValue(value: unknown): Record<string, unknown>;
14
25
  export type SpanKind = 'llm' | 'http' | 'media' | 'agent' | 'tool' | 'mcp' | 'other';
15
26
  export interface Span {
16
27
  traceId: string;
17
28
  spanId: string;
29
+ /** The span this one runs under. Without it every span is a sibling and a backend
30
+ * draws a flat list instead of a tree — so a run reads as "9 things happened", not
31
+ * "a turn, which called a tool, which asked a second model".
32
+ *
33
+ * Resolved in this order: the innermost container span still open on this trace
34
+ * (`agent.run` / `tool.call`), else the app's span from a supplied `traceparent`,
35
+ * else none — this span is the root. */
36
+ parentSpanId?: string;
18
37
  name: string;
19
38
  kind: SpanKind;
20
39
  startTime: number;
@@ -23,6 +42,34 @@ export interface Span {
23
42
  status: 'unset' | 'ok' | 'error';
24
43
  attributes: Record<string, unknown>;
25
44
  }
45
+ /** What kind of work an event describes. `message` is conversation content, which is not
46
+ * a span — it is the thing you want in a debug store and NOT in your metrics backend,
47
+ * which is exactly why it filters separately. */
48
+ export type TraceEventType = 'agent' | 'tool' | 'llm' | 'http' | 'mcp' | 'media' | 'message' | 'other';
49
+ /** One piece of work, carrying enough of the tree that a consumer can push it straight
50
+ * into their own tracer without reconstructing anything. */
51
+ export interface TraceEvent {
52
+ type: TraceEventType;
53
+ /** The app's trace when it supplied a `traceparent`, else ours. */
54
+ traceId: string;
55
+ spanId: string;
56
+ /** Already resolved past anything this subscriber filtered out — see `survivingParent`. */
57
+ parentSpanId?: string;
58
+ /** The conventional name (`chat gpt-5.4-nano`, `execute_tool search`). */
59
+ name: string;
60
+ startTime: number;
61
+ endTime?: number;
62
+ durationMs?: number;
63
+ status: 'unset' | 'ok' | 'error';
64
+ attributes: Record<string, unknown>;
65
+ }
66
+ /** Declarative on purpose, rather than a predicate: knowing the types up front lets a
67
+ * filtered-out event cost nothing, where a predicate would force us to build the payload
68
+ * just to let the caller throw it away. */
69
+ export interface TraceFilter {
70
+ types?: readonly TraceEventType[];
71
+ }
72
+ export type TraceHandler = (event: TraceEvent) => void;
26
73
  export interface TelemetryEvent {
27
74
  seq: number;
28
75
  time: number;
@@ -50,6 +97,13 @@ export interface TelemetryMetrics {
50
97
  avg: number;
51
98
  };
52
99
  }
100
+ /** Parse a W3C `traceparent`: `00-<32 hex trace>-<16 hex span>-<flags>`.
101
+ * Returns null for anything malformed or for the all-zero ids the spec forbids —
102
+ * a bad header must not silently reroute telemetry into a garbage trace. */
103
+ export declare function parseTraceparent(value: string | undefined): {
104
+ traceId: string;
105
+ spanId: string;
106
+ } | null;
53
107
  /** OpenTelemetry Resource — identifies the SERVICE producing this telemetry, so
54
108
  * a shared backend can separate streams from different apps and attribute cost
55
109
  * per service (`sum by service.name`). Stamped on every span/metric/log. */
@@ -80,6 +134,32 @@ export interface TelemetryAdapterOptions {
80
134
  * boundary (a shared collector, a vendor APM) and the message is replaced by a
81
135
  * fixed `[redacted]` string while name/code/status are kept for triage. */
82
136
  includeSensitiveData?: boolean;
137
+ /** Which event types to hand to `onTrace`. Omitted → everything.
138
+ *
139
+ * Filtering SPLICES the tree rather than punching holes in it: drop `http` and the
140
+ * spans under it re-parent to the nearest surviving ancestor. Dropping without that
141
+ * leaves orphans, and a backend draws an orphan as a second root — worse than not
142
+ * filtering at all. */
143
+ types?: readonly TraceEventType[];
144
+ /** Whether conversation content rides along on `message` events. Default `'none'`:
145
+ * prompts and completions are the debugging gold AND the PII, so sending them is a
146
+ * decision to make on purpose rather than inherit. `'full'` adds the Opt-In
147
+ * `gen_ai.input.messages` / `gen_ai.output.messages` attributes; `'none'` still
148
+ * reports the shape (counts and sizes), which is enough to spot a runaway prompt. */
149
+ content?: 'none' | 'full';
150
+ /** Fraction of TRACES to emit, 0..1. Default 1.
151
+ *
152
+ * Per trace, never per span: sampling spans independently shreds every tree it touches
153
+ * — a tool call with no run, a model call with no tool. The decision is a hash of the
154
+ * trace id, so it is stable across processes and two services sharing a trace agree
155
+ * without coordinating.
156
+ *
157
+ * This is HEAD sampling: the choice is made when the trace first appears, before we
158
+ * know whether it ends in an error. Keeping all errors needs tail sampling, which
159
+ * needs buffering; do that in your collector, which is built for it. */
160
+ sample?: number;
161
+ /** Convenience for the common case of a single sink — same as calling `onTrace`. */
162
+ onTrace?: TraceHandler;
83
163
  }
84
164
  export declare class TelemetryAdapter {
85
165
  readonly events: TelemetryEvent[];
@@ -88,15 +168,79 @@ export declare class TelemetryAdapter {
88
168
  /** Service identity stamped on exported telemetry. */
89
169
  readonly resource: TelemetryResource;
90
170
  private seq;
171
+ /** Discriminator for POINT spans (media, mcp connect/tool), whose natural keys are
172
+ * not unique — the same server reconnects, a run emits two images, two tool calls
173
+ * land in one millisecond. A duplicate span id inside a trace is invalid OTLP and
174
+ * the backend silently keeps only one. */
175
+ private spanSeq;
176
+ /** Per trace: the app's span from a `traceparent`, and the CONTAINER spans currently
177
+ * open on it. Together they decide what a new span hangs under — see `parentFor`.
178
+ * Both are cleared once a trace has nothing open, so a long-lived process does not
179
+ * accumulate an entry per conversation forever.
180
+ *
181
+ * A list, not a single slot: an agent nested in a tool call (C2 inside C1's tool) is a
182
+ * second run on the SAME trace, and with one slot it overwrote its own parent and then
183
+ * deleted it on close — leaving the rest of the outer run parentless. */
184
+ private appParent;
185
+ private containers;
91
186
  private latSum;
92
187
  private readonly open;
93
188
  private readonly maxEvents;
94
189
  private readonly includeSensitiveData;
95
190
  private readonly unsub;
191
+ /** Subscribers, each with its own filter. Re-parenting is computed PER SINK: two
192
+ * consumers asking for different types each get a tree that is correct for them. */
193
+ private readonly sinks;
194
+ private readonly content;
195
+ private readonly sampleRate;
196
+ /** spanId → its parent and type, for EVERY span including filtered ones — walking up
197
+ * past a dropped ancestor is the whole point, so the dropped ones must still be here.
198
+ * Bounded, because a long-lived process would otherwise remember every span it ever
199
+ * saw. */
200
+ private readonly lineage;
201
+ private readonly maxLineage;
202
+ private msgSeq;
96
203
  constructor(hooks: HookBus, opts?: TelemetryAdapterOptions);
204
+ /** Subscribe to the event stream. Returns an unsubscribe function.
205
+ *
206
+ * ```ts
207
+ * const stop = telemetry.onTrace({ types: ['agent', 'tool'] }, (e) => pipeline.push(e));
208
+ * ```
209
+ */
210
+ onTrace(handler: TraceHandler): () => void;
211
+ onTrace(filter: TraceFilter, handler: TraceHandler): () => void;
212
+ /** Record a finished span and hand it to the subscribers. Every span reaches the store
213
+ * through here, so there is one place where an event can be missed rather than five. */
214
+ private recordSpan;
215
+ private remember;
216
+ private dispatch;
217
+ /** The nearest ancestor this subscriber actually receives. Without this, filtering out
218
+ * `http` would leave its children pointing at a span that never arrives, and a backend
219
+ * renders a dangling parent as a separate root. */
220
+ private survivingParent;
221
+ /** Hashed rather than random, so the same trace samples the same way in every process
222
+ * and a trace shared by two services is kept or dropped by both. */
223
+ private isSampled;
224
+ /** Conversation content, as its own event so it can be routed somewhere different from
225
+ * the spans — a debug store, not the metrics backend. */
226
+ private emitMessage;
97
227
  /** Stop tapping the bus. */
98
228
  destroy(): void;
99
229
  private handle;
230
+ /** What a new span on this trace hangs under: the innermost container still open on
231
+ * it, else the app's span, else nothing (we are the root).
232
+ *
233
+ * A container wins over the app's span because an LLM call made during a run belongs
234
+ * to that run — attaching it straight to the app would flatten the very nesting the
235
+ * tree exists to show. A span joins the stack only after it is built, so nothing can
236
+ * become its own parent, and a run nested in a tool call lands under that tool call —
237
+ * exactly where it happened.
238
+ *
239
+ * Limit worth naming: with tools running in parallel two `tool.call` spans are open at
240
+ * once and "innermost" is merely the more recent one. Attributing a nested run to the
241
+ * right sibling needs real async context propagation, which this adapter does not
242
+ * have; sequential tools, the common case, are exact. */
243
+ private parentFor;
100
244
  private openSpan;
101
245
  private closeSpan;
102
246
  private recordLatency;
@@ -25,6 +25,17 @@ export interface RequestContext {
25
25
  * server/agent set it, else the LLM client mints in buildContext). Follows
26
26
  * the whole chain; the request half of the trace id. */
27
27
  requestId?: string;
28
+ /** W3C trace context of the span this work runs UNDER — `00-<32 hex trace>-<16 hex
29
+ * span>-<flags>`, exactly the `traceparent` header shape.
30
+ *
31
+ * Pass it and the SDK stops rooting its own trace: its spans join that trace and hang
32
+ * under that span. Without it the library cannot know it is inside an application's
33
+ * request, so the business chain and the model calls reach the backend as two
34
+ * unrelated traces.
35
+ *
36
+ * Sources: the inbound `traceparent` header, or an active span from an OTel SDK if
37
+ * the app already runs one. */
38
+ traceparent?: string;
28
39
  /** = `history.id`. Stable for the lifetime of the conversation. */
29
40
  conversationId?: string;
30
41
  /** Unique per `.complete()` / `.stream()` call. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@combycode/llm-sdk",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Unified, pluggable AI SDK for accessing the LLMs of every major provider (Anthropic, OpenAI, Google, xAI, OpenRouter) through one API. Cross-environment: Node, Bun, and the browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,7 +42,10 @@
42
42
  "lint:fix": "biome lint --write src tests",
43
43
  "format": "biome format --write src tests",
44
44
  "check": "biome check src tests",
45
- "check:fix": "biome check --write src tests"
45
+ "check:fix": "biome check --write src tests",
46
+ "gate": "node ../../quality-gate/gate.mjs",
47
+ "gate:selftest": "node ../../quality-gate/selftest.mjs",
48
+ "gate:snapshot": "node ../../quality-gate/gate.mjs --only api-snapshot --update"
46
49
  },
47
50
  "devDependencies": {
48
51
  "@biomejs/biome": "^2.4.13",