@jaypie/llm 1.2.39 → 1.2.40

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * LLM Observability span kinds supported by Datadog's `llmobs` SDK.
3
+ * @see https://docs.datadoghq.com/llm_observability/setup/sdk/nodejs/
4
+ */
5
+ export type LlmObsSpanKind = "agent" | "embedding" | "llm" | "retrieval" | "task" | "tool" | "workflow";
6
+ export interface LlmObsSpanOptions {
7
+ kind: LlmObsSpanKind;
8
+ modelName?: string;
9
+ modelProvider?: string;
10
+ name: string;
11
+ }
12
+ export interface LlmObsAnnotation {
13
+ inputData?: unknown;
14
+ metadata?: Record<string, unknown>;
15
+ metrics?: Record<string, number>;
16
+ outputData?: unknown;
17
+ tags?: Record<string, unknown>;
18
+ }
19
+ /** Manual span handle for streaming, where a callback cannot enclose the work. */
20
+ export interface LlmObsSpanHandle {
21
+ annotate: (annotation: LlmObsAnnotation) => void;
22
+ finish: () => void;
23
+ }
24
+ /** Minimal shape of the dd-trace `llmobs` SDK surface this module uses. */
25
+ interface LlmObsSdk {
26
+ annotate: (span: unknown, annotation: LlmObsAnnotation) => void;
27
+ trace: (options: LlmObsSpanOptions, fn: (span: unknown) => unknown) => unknown;
28
+ }
29
+ /** Reset the cached SDK resolution. Exposed for tests. */
30
+ export declare function _resetLlmObs(): void;
31
+ /**
32
+ * Inject an SDK to bypass dd-trace resolution. Test-only: dd-trace is not a
33
+ * dependency, so the enabled path cannot otherwise be exercised in unit tests.
34
+ */
35
+ export declare function _setLlmObs(sdk: LlmObsSdk | null): void;
36
+ /**
37
+ * True when LLM Observability emission is opted in via `DD_LLMOBS_ENABLED`.
38
+ * Accepts any truthy value except "false"/"0".
39
+ */
40
+ export declare function isLlmObsEnabled(): boolean;
41
+ /**
42
+ * Run `fn` inside an LLM Observability span. When emission is disabled or
43
+ * dd-trace is absent, `fn` is invoked directly with no overhead. The active
44
+ * span is established for the duration of `fn`, so nested `withLlmObsSpan`
45
+ * calls (and `annotateLlmObs`) attach as children/annotations automatically.
46
+ */
47
+ export declare function withLlmObsSpan<T>(options: LlmObsSpanOptions, fn: () => Promise<T>): Promise<T>;
48
+ /**
49
+ * Annotate the currently active LLM Observability span. No-op when disabled.
50
+ */
51
+ export declare function annotateLlmObs(annotation: LlmObsAnnotation): void;
52
+ /**
53
+ * Open a manual span that is finished later. Used by streaming, where work is
54
+ * yielded incrementally and cannot be enclosed in a single callback. The span
55
+ * is kept open via a deferred promise resolved by `finish()`. Returns null when
56
+ * emission is disabled or dd-trace is absent.
57
+ *
58
+ * Note: because the span is held open across `yield` boundaries (outside the
59
+ * SDK's AsyncLocalStorage scope), manual spans are emitted flat rather than
60
+ * nested. Annotations are applied to the explicit span reference.
61
+ */
62
+ export declare function openLlmObsSpan(options: LlmObsSpanOptions): LlmObsSpanHandle | null;
63
+ /**
64
+ * Sum an `LlmUsage` array into LLM Observability metric keys. Returns undefined
65
+ * when there is no usage data so callers can omit the `metrics` field.
66
+ */
67
+ export declare function usageToLlmObsMetrics(usage?: Array<{
68
+ input?: number;
69
+ output?: number;
70
+ total?: number;
71
+ }>): Record<string, number> | undefined;
72
+ export {};
package/dist/esm/index.js CHANGED
@@ -5,6 +5,8 @@ import { placeholders, JAYPIE, resolveValue, sleep } from '@jaypie/kit';
5
5
  import RandomLib from 'random';
6
6
  import { RateLimitError, APIConnectionError, APIConnectionTimeoutError, InternalServerError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, NotFoundError, PermissionDeniedError, UnprocessableEntityError, OpenAI } from 'openai';
7
7
  import { zodResponseFormat } from 'openai/helpers/zod';
8
+ import { createRequire } from 'module';
9
+ import { pathToFileURL } from 'url';
8
10
  import { PDFDocument } from 'pdf-lib';
9
11
  import { readFile } from 'fs/promises';
10
12
  import { resolve } from 'path';
@@ -4835,6 +4837,198 @@ class Toolkit {
4835
4837
  }
4836
4838
  }
4837
4839
 
4840
+ //
4841
+ //
4842
+ // Constants
4843
+ //
4844
+ const ENV = {
4845
+ DD_LLMOBS_ENABLED: "DD_LLMOBS_ENABLED",
4846
+ };
4847
+ const MODULE = {
4848
+ // Computed at runtime so bundlers (esbuild) do not attempt to include
4849
+ // dd-trace, which is provided by the Datadog Lambda layer at runtime.
4850
+ DD_TRACE: ["dd", "trace"].join("-"),
4851
+ };
4852
+ //
4853
+ //
4854
+ // Helpers
4855
+ //
4856
+ // CJS/ESM compatible require - handles bundling to CJS where import.meta.url
4857
+ // becomes undefined (mirrors packages/testkit/src/mock/original.ts).
4858
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
4859
+ // @ts-ignore - __filename exists in CJS context when ESM is bundled to CJS
4860
+ const requireModule = typeof __filename !== "undefined"
4861
+ ? createRequire(pathToFileURL(__filename).href)
4862
+ : createRequire(import.meta.url);
4863
+ let resolved = false;
4864
+ let cachedSdk$4 = null;
4865
+ /**
4866
+ * Lazily resolve the dd-trace `llmobs` SDK. Returns null (and never throws)
4867
+ * when dd-trace is absent or the SDK surface is unexpected. Cached after the
4868
+ * first attempt.
4869
+ */
4870
+ function resolveLlmObs() {
4871
+ if (resolved) {
4872
+ return cachedSdk$4;
4873
+ }
4874
+ resolved = true;
4875
+ try {
4876
+ const ddTrace = requireModule(MODULE.DD_TRACE);
4877
+ const tracer = ddTrace?.default ?? ddTrace;
4878
+ const llmobs = tracer?.llmobs;
4879
+ if (llmobs &&
4880
+ typeof llmobs.trace === "function" &&
4881
+ typeof llmobs.annotate === "function") {
4882
+ cachedSdk$4 = llmobs;
4883
+ }
4884
+ }
4885
+ catch {
4886
+ cachedSdk$4 = null;
4887
+ }
4888
+ return cachedSdk$4;
4889
+ }
4890
+ //
4891
+ //
4892
+ // Main
4893
+ //
4894
+ /**
4895
+ * True when LLM Observability emission is opted in via `DD_LLMOBS_ENABLED`.
4896
+ * Accepts any truthy value except "false"/"0".
4897
+ */
4898
+ function isLlmObsEnabled() {
4899
+ const value = process.env[ENV.DD_LLMOBS_ENABLED];
4900
+ if (!value) {
4901
+ return false;
4902
+ }
4903
+ const normalized = value.trim().toLowerCase();
4904
+ return normalized !== "" && normalized !== "false" && normalized !== "0";
4905
+ }
4906
+ /** Resolve the SDK only when enabled and available. */
4907
+ function activeSdk() {
4908
+ if (!isLlmObsEnabled()) {
4909
+ return null;
4910
+ }
4911
+ return resolveLlmObs();
4912
+ }
4913
+ /**
4914
+ * Run `fn` inside an LLM Observability span. When emission is disabled or
4915
+ * dd-trace is absent, `fn` is invoked directly with no overhead. The active
4916
+ * span is established for the duration of `fn`, so nested `withLlmObsSpan`
4917
+ * calls (and `annotateLlmObs`) attach as children/annotations automatically.
4918
+ */
4919
+ async function withLlmObsSpan(options, fn) {
4920
+ const sdk = activeSdk();
4921
+ if (!sdk) {
4922
+ return fn();
4923
+ }
4924
+ let started = false;
4925
+ try {
4926
+ return (await sdk.trace(options, () => {
4927
+ started = true;
4928
+ return fn();
4929
+ }));
4930
+ }
4931
+ catch (error) {
4932
+ // If `fn` ran, propagate its error (the SDK re-throws after finishing the
4933
+ // span). Only swallow failures from the instrumentation itself, re-running
4934
+ // `fn` so observability never breaks the underlying call.
4935
+ if (started) {
4936
+ throw error;
4937
+ }
4938
+ getLogger$6().warn("[llmobs] span emission failed");
4939
+ getLogger$6().var({ error });
4940
+ return fn();
4941
+ }
4942
+ }
4943
+ /**
4944
+ * Annotate the currently active LLM Observability span. No-op when disabled.
4945
+ */
4946
+ function annotateLlmObs(annotation) {
4947
+ const sdk = activeSdk();
4948
+ if (!sdk) {
4949
+ return;
4950
+ }
4951
+ try {
4952
+ sdk.annotate(undefined, annotation);
4953
+ }
4954
+ catch (error) {
4955
+ getLogger$6().warn("[llmobs] annotate failed");
4956
+ getLogger$6().var({ error });
4957
+ }
4958
+ }
4959
+ /**
4960
+ * Open a manual span that is finished later. Used by streaming, where work is
4961
+ * yielded incrementally and cannot be enclosed in a single callback. The span
4962
+ * is kept open via a deferred promise resolved by `finish()`. Returns null when
4963
+ * emission is disabled or dd-trace is absent.
4964
+ *
4965
+ * Note: because the span is held open across `yield` boundaries (outside the
4966
+ * SDK's AsyncLocalStorage scope), manual spans are emitted flat rather than
4967
+ * nested. Annotations are applied to the explicit span reference.
4968
+ */
4969
+ function openLlmObsSpan(options) {
4970
+ const sdk = activeSdk();
4971
+ if (!sdk) {
4972
+ return null;
4973
+ }
4974
+ try {
4975
+ let capturedSpan;
4976
+ let release;
4977
+ const held = new Promise((resolve) => {
4978
+ release = resolve;
4979
+ });
4980
+ // trace() runs the callback synchronously, capturing the span; the returned
4981
+ // pending promise keeps the span open until finish() resolves it.
4982
+ void sdk.trace(options, (span) => {
4983
+ capturedSpan = span;
4984
+ return held;
4985
+ });
4986
+ let finished = false;
4987
+ return {
4988
+ annotate: (annotation) => {
4989
+ try {
4990
+ sdk.annotate(capturedSpan, annotation);
4991
+ }
4992
+ catch (error) {
4993
+ getLogger$6().warn("[llmobs] annotate failed");
4994
+ getLogger$6().var({ error });
4995
+ }
4996
+ },
4997
+ finish: () => {
4998
+ if (finished) {
4999
+ return;
5000
+ }
5001
+ finished = true;
5002
+ release?.();
5003
+ },
5004
+ };
5005
+ }
5006
+ catch (error) {
5007
+ getLogger$6().warn("[llmobs] span emission failed");
5008
+ getLogger$6().var({ error });
5009
+ return null;
5010
+ }
5011
+ }
5012
+ //
5013
+ //
5014
+ // Mapping Helpers
5015
+ //
5016
+ /**
5017
+ * Sum an `LlmUsage` array into LLM Observability metric keys. Returns undefined
5018
+ * when there is no usage data so callers can omit the `metrics` field.
5019
+ */
5020
+ function usageToLlmObsMetrics(usage) {
5021
+ if (!usage || usage.length === 0) {
5022
+ return undefined;
5023
+ }
5024
+ const metrics = usage.reduce((sum, item) => ({
5025
+ input_tokens: sum.input_tokens + (item.input ?? 0),
5026
+ output_tokens: sum.output_tokens + (item.output ?? 0),
5027
+ total_tokens: sum.total_tokens + (item.total ?? 0),
5028
+ }), { input_tokens: 0, output_tokens: 0, total_tokens: 0 });
5029
+ return metrics;
5030
+ }
5031
+
4838
5032
  //
4839
5033
  //
4840
5034
  // Main
@@ -5706,30 +5900,46 @@ class OperateLoop {
5706
5900
  // Initialize state
5707
5901
  const state = await this.initializeState(input, options);
5708
5902
  const context = this.createContext(options);
5709
- // Build initial request
5710
- let request = this.buildInitialRequest(state, options);
5711
- // Multi-turn loop
5712
- while (state.currentTurn < state.maxTurns) {
5713
- state.currentTurn++;
5714
- // Execute one turn with retry logic
5715
- const shouldContinue = await this.executeOneTurn(request, state, context, options);
5716
- if (!shouldContinue) {
5717
- break;
5903
+ const modelName = options.model ?? this.adapter.defaultModel;
5904
+ // Enclosing LLM Observability span (no-op when DD_LLMOBS_ENABLED is unset).
5905
+ // Child llm/tool spans nest under it via the SDK's active-span context.
5906
+ return withLlmObsSpan({
5907
+ kind: state.toolkit ? "agent" : "llm",
5908
+ modelName,
5909
+ modelProvider: this.adapter.name,
5910
+ name: "jaypie.llm.operate",
5911
+ }, async () => {
5912
+ // Build initial request
5913
+ let request = this.buildInitialRequest(state, options);
5914
+ // Multi-turn loop
5915
+ while (state.currentTurn < state.maxTurns) {
5916
+ state.currentTurn++;
5917
+ // Execute one turn with retry logic
5918
+ const shouldContinue = await this.executeOneTurn(request, state, context, options);
5919
+ if (!shouldContinue) {
5920
+ break;
5921
+ }
5922
+ // Rebuild request with updated history for next turn
5923
+ request = {
5924
+ format: state.formattedFormat,
5925
+ instructions: options.instructions,
5926
+ messages: state.currentInput,
5927
+ model: modelName,
5928
+ providerOptions: options.providerOptions,
5929
+ system: options.system,
5930
+ temperature: options.temperature,
5931
+ tools: state.formattedTools,
5932
+ user: options.user,
5933
+ };
5718
5934
  }
5719
- // Rebuild request with updated history for next turn
5720
- request = {
5721
- format: state.formattedFormat,
5722
- instructions: options.instructions,
5723
- messages: state.currentInput,
5724
- model: options.model ?? this.adapter.defaultModel,
5725
- providerOptions: options.providerOptions,
5726
- system: options.system,
5727
- temperature: options.temperature,
5728
- tools: state.formattedTools,
5729
- user: options.user,
5730
- };
5731
- }
5732
- return state.responseBuilder.build();
5935
+ const response = state.responseBuilder.build();
5936
+ annotateLlmObs({
5937
+ inputData: input,
5938
+ metrics: usageToLlmObsMetrics(response.usage),
5939
+ outputData: response.content,
5940
+ });
5941
+ return response;
5942
+ });
5733
5943
  }
5734
5944
  //
5735
5945
  // Private Methods
@@ -5831,20 +6041,34 @@ class OperateLoop {
5831
6041
  options,
5832
6042
  providerRequest,
5833
6043
  });
5834
- // Execute with retry (RetryExecutor handles error hooks and throws appropriate errors)
5835
- const response = await retryExecutor.execute((signal) => this.adapter.executeRequest(this.client, providerRequest, signal), {
5836
- context: {
5837
- input: state.currentInput,
5838
- options,
5839
- providerRequest,
5840
- },
5841
- hooks: context.hooks,
6044
+ // Execute with retry inside a child llm span (no-op when llmobs disabled).
6045
+ // RetryExecutor handles error hooks and throws appropriate errors.
6046
+ const { parsed, response } = await withLlmObsSpan({
6047
+ kind: "llm",
6048
+ modelName: options.model ?? this.adapter.defaultModel,
6049
+ modelProvider: this.adapter.name,
6050
+ name: "jaypie.llm.model",
6051
+ }, async () => {
6052
+ const response = await retryExecutor.execute((signal) => this.adapter.executeRequest(this.client, providerRequest, signal), {
6053
+ context: {
6054
+ input: state.currentInput,
6055
+ options,
6056
+ providerRequest,
6057
+ },
6058
+ hooks: context.hooks,
6059
+ });
6060
+ // Log what was returned from the model
6061
+ log.trace("[operate] Model response received");
6062
+ log.var({ "operate.response": response });
6063
+ // Parse response
6064
+ const parsed = this.adapter.parseResponse(response, options);
6065
+ annotateLlmObs({
6066
+ inputData: state.currentInput,
6067
+ metrics: usageToLlmObsMetrics(parsed.usage ? [parsed.usage] : undefined),
6068
+ outputData: parsed.content ?? "",
6069
+ });
6070
+ return { parsed, response };
5842
6071
  });
5843
- // Log what was returned from the model
5844
- log.trace("[operate] Model response received");
5845
- log.var({ "operate.response": response });
5846
- // Parse response
5847
- const parsed = this.adapter.parseResponse(response, options);
5848
6072
  // Track usage
5849
6073
  if (parsed.usage) {
5850
6074
  state.responseBuilder.addUsage(parsed.usage);
@@ -5889,11 +6113,19 @@ class OperateLoop {
5889
6113
  args: toolCall.arguments,
5890
6114
  toolName: toolCall.name,
5891
6115
  });
5892
- // Call the tool
6116
+ // Call the tool inside a child tool span (no-op when disabled)
5893
6117
  log.trace(`[operate] Calling tool - ${toolCall.name}`);
5894
- const result = await state.toolkit.call({
5895
- arguments: toolCall.arguments,
5896
- name: toolCall.name,
6118
+ const result = await withLlmObsSpan({ kind: "tool", name: toolCall.name }, async () => {
6119
+ const result = await state.toolkit.call({
6120
+ arguments: toolCall.arguments,
6121
+ name: toolCall.name,
6122
+ });
6123
+ annotateLlmObs({
6124
+ inputData: toolCall.arguments,
6125
+ metadata: { tool: toolCall.name },
6126
+ outputData: result,
6127
+ });
6128
+ return result;
5897
6129
  });
5898
6130
  // Execute afterEachTool hook
5899
6131
  await this.hookRunnerInstance.runAfterTool(context.hooks, {
@@ -6143,7 +6375,7 @@ class StreamLoop {
6143
6375
  }
6144
6376
  // If we have tool calls, process them
6145
6377
  if (toolCalls && toolCalls.length > 0 && state.toolkit) {
6146
- yield* this.processToolCalls(toolCalls, state, context, options);
6378
+ yield* this.processToolCalls(toolCalls, state, context);
6147
6379
  // Check if we've reached max turns
6148
6380
  if (state.currentTurn >= state.maxTurns) {
6149
6381
  const error = new TooManyRequestsError();
@@ -6250,6 +6482,16 @@ class StreamLoop {
6250
6482
  options,
6251
6483
  providerRequest,
6252
6484
  });
6485
+ // Open a manual llm span held open across the streamed turn. Flat (not
6486
+ // nested) because it spans yield boundaries; no-op when llmobs disabled.
6487
+ const llmSpan = openLlmObsSpan({
6488
+ kind: "llm",
6489
+ modelName: options.model ?? this.adapter.defaultModel,
6490
+ modelProvider: this.adapter.name,
6491
+ name: "jaypie.llm.model",
6492
+ });
6493
+ const inputSnapshot = [...state.currentInput];
6494
+ let streamedText = "";
6253
6495
  // Collect tool calls from the stream
6254
6496
  const collectedToolCalls = [];
6255
6497
  // Retry loop for connection-level failures
@@ -6269,6 +6511,7 @@ class StreamLoop {
6269
6511
  // Pass through text chunks
6270
6512
  if (chunk.type === LlmStreamChunkType.Text) {
6271
6513
  chunksYielded = true;
6514
+ streamedText += chunk.content ?? "";
6272
6515
  yield chunk;
6273
6516
  }
6274
6517
  // Collect tool calls
@@ -6315,6 +6558,12 @@ class StreamLoop {
6315
6558
  title: "Stream Error",
6316
6559
  },
6317
6560
  };
6561
+ llmSpan?.annotate({
6562
+ inputData: inputSnapshot,
6563
+ metrics: usageToLlmObsMetrics(state.usageItems),
6564
+ outputData: streamedText,
6565
+ });
6566
+ llmSpan?.finish();
6318
6567
  return { shouldContinue: false };
6319
6568
  }
6320
6569
  // Check if we've exhausted retries or error is not retryable
@@ -6323,6 +6572,7 @@ class StreamLoop {
6323
6572
  log.error(`Stream request failed after ${this.retryPolicy.maxRetries} retries`);
6324
6573
  log.var({ error });
6325
6574
  const errorMessage = error instanceof Error ? error.message : String(error);
6575
+ llmSpan?.finish();
6326
6576
  throw new BadGatewayError(errorMessage);
6327
6577
  }
6328
6578
  const delay = this.retryPolicy.getDelayForAttempt(attempt);
@@ -6336,6 +6586,13 @@ class StreamLoop {
6336
6586
  finally {
6337
6587
  guard.remove();
6338
6588
  }
6589
+ // Annotate and finish the streamed llm span (no-op when disabled)
6590
+ llmSpan?.annotate({
6591
+ inputData: inputSnapshot,
6592
+ metrics: usageToLlmObsMetrics(state.usageItems),
6593
+ outputData: streamedText,
6594
+ });
6595
+ llmSpan?.finish();
6339
6596
  // Execute afterEachModelResponse hook
6340
6597
  await this.hookRunnerInstance.runAfterModelResponse(context.hooks, {
6341
6598
  content: "",
@@ -6370,7 +6627,7 @@ class StreamLoop {
6370
6627
  }
6371
6628
  return { shouldContinue: false };
6372
6629
  }
6373
- async *processToolCalls(toolCalls, state, context, _options) {
6630
+ async *processToolCalls(toolCalls, state, context) {
6374
6631
  const log = getLogger$6();
6375
6632
  for (const toolCall of toolCalls) {
6376
6633
  try {
@@ -6379,11 +6636,19 @@ class StreamLoop {
6379
6636
  args: toolCall.arguments,
6380
6637
  toolName: toolCall.name,
6381
6638
  });
6382
- // Call the tool
6639
+ // Call the tool inside a child tool span (no-op when disabled)
6383
6640
  log.trace(`[stream] Calling tool - ${toolCall.name}`);
6384
- const result = await state.toolkit.call({
6385
- arguments: toolCall.arguments,
6386
- name: toolCall.name,
6641
+ const result = await withLlmObsSpan({ kind: "tool", name: toolCall.name }, async () => {
6642
+ const result = await state.toolkit.call({
6643
+ arguments: toolCall.arguments,
6644
+ name: toolCall.name,
6645
+ });
6646
+ annotateLlmObs({
6647
+ inputData: toolCall.arguments,
6648
+ metadata: { tool: toolCall.name },
6649
+ outputData: result,
6650
+ });
6651
+ return result;
6387
6652
  });
6388
6653
  // Execute afterEachTool hook
6389
6654
  await this.hookRunnerInstance.runAfterTool(context.hooks, {